fix(memory): embedding 批上限与超时同源 —— 不再「客户端先放弃、服务端继续算」
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、不动判定规则与任何阈值。
This commit is contained in:
1 parent
68ba85389b
commit
59672720bd
9 files changed
+836
-45
No files matched your search
@@ -119,12 +119,17 @@ func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
|
||||
return stats, err
|
||||
}
|
||||
stats.RunID = runID
|
||||
// ledger 是**本轮实际发生**的召回来源记账口(不是配置里那一种):生产轮可以
|
||||
// 多分区混合(有的走语义、有的降级),因此汇总行只能由实测记录推出。
|
||||
ledger := &recallLedger{}
|
||||
|
||||
failures := t.scanPartitions(ctx, partitions, &stats, tidyRoundPolicy{
|
||||
sink: func(ctx context.Context, cands []TidyCandidate, stats *TidyStats) {
|
||||
t.persistCandidates(ctx, runID, cands, stats)
|
||||
},
|
||||
recall: t.fallbackRecall,
|
||||
recall: func(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) {
|
||||
return t.fallbackRecall(ctx, p, items, stats, budget, ledger)
|
||||
},
|
||||
})
|
||||
|
||||
// 轮末自动落地(2026-10-07 主人口径):规则层判为高置信的待定候选直接落地,
|
||||
@@ -150,7 +155,7 @@ func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
|
||||
failures = append(failures, err)
|
||||
}
|
||||
|
||||
t.logRun(stats, run)
|
||||
t.logRun(stats, run, ledger)
|
||||
t.auditAutoApply(auto)
|
||||
|
||||
switch {
|
||||
@@ -165,7 +170,14 @@ func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
|
||||
|
||||
// logRun 输出本轮的可观测口径:扫描 N / 候选 M / 建议 K / 否决原因分布。
|
||||
// 它带 source(memory/tidy),因此这条汇总永远可回溯到整理子系统。
|
||||
func (t *Tidier) logRun(stats TidyStats, run TidyRun) {
|
||||
//
|
||||
// ledger 给出的是**本轮实际发生**的召回来源(不是配置态)。这一条是 M6 2026-10-13
|
||||
// 的 medium finding 的落点:旧实现按「有没有配 embedder」写 recall_mode,全量降级
|
||||
// 在汇总行里被写成 embedding。现在:
|
||||
// - recall_mode = "":没有任何分区真正算过相似度(没有候选可比);
|
||||
// - recall_mode = lexical ∧ recall_degraded_partitions > 0:语义不可用、降级了;
|
||||
// - recall_mode = mixed:多分区里一部分走了语义、一部分降级 —— 不再被抹平。
|
||||
func (t *Tidier) logRun(stats TidyStats, run TidyRun, ledger *recallLedger) {
|
||||
fields := []any{
|
||||
"run_id", run.ID,
|
||||
"rule_version", run.RuleVersion,
|
||||
@@ -181,7 +193,8 @@ func (t *Tidier) logRun(stats TidyStats, run TidyRun) {
|
||||
"kept", stats.Kept,
|
||||
"errors", run.Errors,
|
||||
"duration_ms", run.FinishedAt.Sub(run.StartedAt).Milliseconds(),
|
||||
"recall_mode", tidyRecallMode(t.deps.Embedder != nil),
|
||||
"recall_mode", ledger.label(),
|
||||
"recall_degraded_partitions", ledger.degradedPartitions(),
|
||||
"auto_apply", t.cfg.autoApplyEnabled(),
|
||||
"auto_applied", stats.AutoApplied,
|
||||
"auto_merge", stats.AutoMerged,
|
||||
@@ -232,11 +245,15 @@ func (t *Tidier) scanPartitions(ctx context.Context, partitions []tidyPartition,
|
||||
// fallbackRecall 是**生产轮**的召回:相似度来源不可用时降级到零依赖的词项
|
||||
// 余弦(离线/无 embedding 配置时可跑,宁可比语义路径更保守),并记一条 WARN。
|
||||
// 两个来源**不可混用阈值**:阈值只有一个(design §13 开放点 2 的 0.80)。
|
||||
func (t *Tidier) fallbackRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) {
|
||||
pairs, err := t.recallPairs(ctx, p, items, stats, budget)
|
||||
//
|
||||
// 降级这一笔**必须记进 ledger**:汇总行/面板上的召回来源只能由实测记录推出,
|
||||
// 否则「全量降级」会被报成「向量正常」(M6 2026-10-13 的 medium finding)。
|
||||
func (t *Tidier) fallbackRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget, ledger *recallLedger) ([]tidyPair, error) {
|
||||
pairs, err := t.recallPairs(ctx, p, items, stats, budget, ledger)
|
||||
if err == nil {
|
||||
return pairs, nil
|
||||
}
|
||||
ledger.noteLexical(true)
|
||||
t.log.Warn("memory tidy embedding recall unavailable, falling back to lexical",
|
||||
"partition", p.label(), "error", err)
|
||||
return t.collectPairs(p, items, t.cfg.RecallThreshold,
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||||
)
|
||||
|
||||
// 本文件收拢记忆整理(design/23-记忆整理与去重)全部的标识、默认值与旋钮,
|
||||
@@ -74,7 +75,17 @@ const (
|
||||
|
||||
// tidyEmbedBatch 是单次 Embed 调用的文本条数上限,避免把整个分区的文本
|
||||
// 一次性交给远端 embedding API。
|
||||
tidyEmbedBatch = 64
|
||||
//
|
||||
// 🔴 它**不是**拍出来的数字,而是 internal/runtime/embedding 里**同一条推演**
|
||||
// 的结果(budget.go:一次请求的耗时上界 ≤ 单次超时的一半 ⇒ 批上限 = 超时的一半
|
||||
// ÷ 单条预算上界)。2026-10-13:旧值 64 的实测耗时 7.5~8.5s 已越过客户端 5s
|
||||
// 单次超时 ⇒ 客户端放弃、服务端继续算 ⇒ 本机 embedding 服务被顶住,单条请求
|
||||
// (写向量索引)也跟着超时。把批上限与超时绑到同一条推演上,就不会再出现
|
||||
// 「只调一侧、另一侧静默失效」。
|
||||
//
|
||||
// 批上限在 embedder 内部**还有一道强制**(Embed 自己切片),因此这里即使被改大
|
||||
// 也不会把服务端顶住;这一行只是让调用方别去构造注定被切开的超大切片。
|
||||
tidyEmbedBatch = embedding.DefaultBatchLimit
|
||||
|
||||
// minTidyContentRunes 是参与比对的最小内容长度(按 rune)。过短的内容
|
||||
// (如单字)没有可判定的事实要素,进比对只会产出噪声,直接跳过。
|
||||
@@ -126,6 +137,11 @@ const (
|
||||
maxTidyRejectBatchLimit = 10000
|
||||
)
|
||||
|
||||
// 编译期不变式(2026-10-13):批上限一旦被调回超过 embedding 推出来的值
|
||||
// (例:写回旧值 64),下面这行常量表达式会变成负数、uint 溢出 ⇒ **编译失败**。
|
||||
// 这条断言把「批上限与超时同源」从注释里的承诺变成编译器守的门。
|
||||
const _ = uint(embedding.DefaultBatchLimit - tidyEmbedBatch)
|
||||
|
||||
// TidyConfig 携带记忆整理的调优旋钮。零值经 normalize 选取安全默认值,
|
||||
// 因此半填的 Config 仍可用。
|
||||
type TidyConfig struct {
|
||||
|
||||
@@ -52,11 +52,15 @@ type TidyDryRun struct {
|
||||
// 「完整的建议」展示)。
|
||||
func (t *Tidier) DryRun(ctx context.Context) (TidyDryRun, error) {
|
||||
started := t.now()
|
||||
out := TidyDryRun{RecallMode: tidyRecallMode(t.deps.Embedder != nil)}
|
||||
out := TidyDryRun{}
|
||||
stats, partitions, err := t.beginRound(ctx)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
// ledger 是**本轮实际发生**的召回来源:试算会写进 RecallMode 返回给面板。
|
||||
// 旧实现按「有没有配 embedder」写(tidyRecallMode(deps.Embedder != nil)),
|
||||
// 那会让面板把「向量不可用」读成「向量正常」(M6 2026-10-13 的 medium finding)。
|
||||
ledger := &recallLedger{}
|
||||
|
||||
// 收集出口:闭包只在 scanPartitions 的**串行**循环里被调用,因此无需加锁。
|
||||
collected := make([]TidyCandidate, 0)
|
||||
@@ -64,12 +68,17 @@ func (t *Tidier) DryRun(ctx context.Context) (TidyDryRun, error) {
|
||||
sink: func(_ context.Context, cands []TidyCandidate, _ *TidyStats) {
|
||||
collected = append(collected, cands...)
|
||||
},
|
||||
recall: t.strictRecall,
|
||||
recall: func(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) {
|
||||
return t.strictRecall(ctx, p, items, stats, budget, ledger)
|
||||
},
|
||||
}
|
||||
failures := t.scanPartitions(ctx, partitions, &stats, policy)
|
||||
|
||||
out.Stats = stats
|
||||
out.Candidates = collected
|
||||
// 如实上报**实际发生**的来源:空串 = 没有任何分区真正算过相似度(「没候选」,
|
||||
// 与「向量不可用」不是一回事)。
|
||||
out.RecallMode = ledger.label()
|
||||
out.DurationMS = t.now().Sub(started).Milliseconds()
|
||||
if len(failures) > 0 {
|
||||
return out, fmt.Errorf("memory/tidy: dry-run finished with errors: %w", errors.Join(failures...))
|
||||
@@ -80,8 +89,8 @@ func (t *Tidier) DryRun(ctx context.Context) (TidyDryRun, error) {
|
||||
|
||||
// strictRecall 是**试算轮**的召回:语义召回不可用时**不**降级,如实上报。
|
||||
// 日志只记分区与错误,绝不带记忆正文(候选预览里已含正文)。
|
||||
func (t *Tidier) strictRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) {
|
||||
pairs, err := t.recallPairs(ctx, p, items, stats, budget)
|
||||
func (t *Tidier) strictRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget, ledger *recallLedger) ([]tidyPair, error) {
|
||||
pairs, err := t.recallPairs(ctx, p, items, stats, budget, ledger)
|
||||
if err != nil {
|
||||
t.log.Warn("memory tidy dry-run recall unavailable",
|
||||
"partition", p.label(), "items", len(items), "error", err)
|
||||
|
||||
@@ -25,8 +25,75 @@ const (
|
||||
tidyRecallEmbedding = "embedding"
|
||||
// tidyRecallLexical 是本轮相似度来自零依赖的词项余弦。
|
||||
tidyRecallLexical = "lexical"
|
||||
// tidyRecallMixed 是多分区的一轮里**部分**分区走了语义、部分降级到词项余弦。
|
||||
// 它取代「挑一个代表值报上去」的做法:降级一旦发生就必须看得见。
|
||||
tidyRecallMixed = "mixed"
|
||||
)
|
||||
|
||||
// recallLedger 记录**一轮里实际发生**的召回来源(不是配置里那一种)。
|
||||
//
|
||||
// 为什么需要它(M6 2026-10-13 的 medium finding):整理轮与试算轮的 `recall_mode`
|
||||
// 曾按「有没有配 embedder」写出去(旧实现 tidyRecallMode(hasEmbedder bool)),于是
|
||||
// **全量降级**在汇总行/面板里被写成 `embedding` —— 汇总与事实相反,运维据此判读会
|
||||
// 把「向量不可用」读成「向量正常工作」。本类型把口径翻过来:只在**真的用了**那条
|
||||
// 路的地方记一笔,label() 完全由这些实测记录推出。
|
||||
type recallLedger struct {
|
||||
// embedding / lexical 是**真正用该来源算过相似度**的分区数。
|
||||
embedding int
|
||||
lexical int
|
||||
// degraded 是**因相似度来源不可用**而降级到词项余弦的分区数(不是「没配
|
||||
// embedder」那种配置形态)。它把「没候选」(label 为空串)与「向量不可用」
|
||||
// (label 为 lexical 且 degraded > 0)分开 —— 这两件事在运维上不是一回事。
|
||||
degraded int
|
||||
}
|
||||
|
||||
// noteEmbedding 记一笔「这个分区真的用了语义 embedding」。
|
||||
func (l *recallLedger) noteEmbedding() {
|
||||
if l != nil {
|
||||
l.embedding++
|
||||
}
|
||||
}
|
||||
|
||||
// noteLexical 记一笔「这个分区用了词项余弦」;degraded 表示它是**降级**来的
|
||||
// (embedder 报错 / 向量条数错位),而不是「本来就没配 embedder」。
|
||||
func (l *recallLedger) noteLexical(degraded bool) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
l.lexical++
|
||||
if degraded {
|
||||
l.degraded++
|
||||
}
|
||||
}
|
||||
|
||||
// label 返回本轮**实际**的召回来源:
|
||||
// - "":没有任何分区真正算过相似度(没有候选可比)—— 「没候选」不是「降级」;
|
||||
// - embedding / lexical:所有算过的分区都走同一条路;
|
||||
// - mixed:两种都有(多分区里有一部分降级)。
|
||||
func (l *recallLedger) label() string {
|
||||
if l == nil {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case l.embedding == 0 && l.lexical == 0:
|
||||
return ""
|
||||
case l.embedding == 0:
|
||||
return tidyRecallLexical
|
||||
case l.lexical == 0:
|
||||
return tidyRecallEmbedding
|
||||
default:
|
||||
return tidyRecallMixed
|
||||
}
|
||||
}
|
||||
|
||||
// degradedPartitions 返回本轮因向量不可用而降级的分区数。
|
||||
func (l *recallLedger) degradedPartitions() int {
|
||||
if l == nil {
|
||||
return 0
|
||||
}
|
||||
return l.degraded
|
||||
}
|
||||
|
||||
// tidyPair 是一条被召回的候选对。
|
||||
type tidyPair struct {
|
||||
a, b MemoryItem
|
||||
@@ -35,18 +102,23 @@ type tidyPair struct {
|
||||
mode string
|
||||
}
|
||||
|
||||
// recallPairs 在分区内召回候选对。条目少于 2 条时为空操作(无候选、不报错)。
|
||||
// recallPairs 在分区内召回候选对。条目少于 2 条时为空操作(无候选、不报错),
|
||||
// 且**不往 ledger 记任何一笔** —— 「没有候选可比」不是「走哪条路召回」。
|
||||
//
|
||||
// 相似度来源不可用时(embedder 报错、向量条数错位)返回错误 —— 回退决策不在
|
||||
// 这里:生产轮把它降级成词项余弦,试算轮如实上报(见 tidyRecall 策略)。
|
||||
//
|
||||
// ledger 是**实际发生**的来源记账口(见 recallLedger):它只在真正选定相似度
|
||||
// 函数的那一行记账,因此谁也无法把它报成「配置里那一种」。
|
||||
//
|
||||
// 返回的对按「分数降序 + id」确定性排序,使每分区候选上限的截断可复现
|
||||
// (同一批数据两次运行产出同一批候选 ⇒ 幂等,design §4.1 A)。
|
||||
func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) {
|
||||
func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget, ledger *recallLedger) ([]tidyPair, error) {
|
||||
if len(items) < 2 {
|
||||
return nil, nil
|
||||
}
|
||||
if t.deps.Embedder == nil {
|
||||
ledger.noteLexical(false)
|
||||
return t.collectPairs(p, items, t.cfg.RecallThreshold,
|
||||
lexicalSimFunc(items), tidyRecallLexical, stats, budget), nil
|
||||
}
|
||||
@@ -57,6 +129,7 @@ func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []Memor
|
||||
if len(vecs) != len(items) {
|
||||
return nil, ErrTidyEmbedCount
|
||||
}
|
||||
ledger.noteEmbedding()
|
||||
return t.collectPairs(p, items, t.cfg.RecallThreshold,
|
||||
vectorSimFunc(vecs), tidyRecallEmbedding, stats, budget), nil
|
||||
}
|
||||
@@ -205,10 +278,6 @@ func clampUnit(v float64) float64 {
|
||||
return v
|
||||
}
|
||||
|
||||
// tidyRecallMode 报告本轮相似度的来源,便于运维判断阈值是否在预期口径上。
|
||||
func tidyRecallMode(hasEmbedder bool) string {
|
||||
if hasEmbedder {
|
||||
return tidyRecallEmbedding
|
||||
}
|
||||
return tidyRecallLexical
|
||||
}
|
||||
// 旧的 tidyRecallMode(hasEmbedder bool) 已删除(M6 2026-10-13 的 medium finding):
|
||||
// 它按**配置态**报「本轮相似度来源」,把全量降级掩盖成 embedding。现在的口径是
|
||||
// recallLedger —— 只报**实际发生**的那一种,见本文件顶部的 recallLedger。
|
||||
@@ -0,0 +1,185 @@
|
||||
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 ""
|
||||
}
|
||||
Reference in new issue
Block a user