Files
Cyrene_v2/internal/runtime/memory/tidy_dryrun.go
T
AskaEth d6ed9cfa2b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 记忆整理「试算(dry-run)」入口 + 面板卡(契约 v0.75)
主人要「现在就想看到在 736 条上它会建议合并哪些」,而不是等 23:00 的低峰首跑。
- 后端:Tidier.DryRun + TidyService.DryRun,**复用同一条扫描→召回→判定→构造候选路径**(抽出 tidyRoundPolicy,生产轮与试算轮只差「候选出口」与「召回降级策略」两处;禁止第二套实现)
- 🔴 零副作用:不写 memory_tidy_candidates / memory_tidy_runs,绝不改 memory_items 任何列;机械证明 = 跑前跑后 cyrene.db 与 -wal 的 SHA256 一致(测试断言「表行数不变」)
- 失败码诚实:500 tidy_unavailable / 409 tidy_disabled / 500 tidy_recall_unavailable(**embedding 不可用不降级成 0 条**)/ 503 tidy_timeout / 500 tidy_dry_run_failed;**空数组唯一含义 = 算过了没建议**
- 端点 POST /api/v1/admin/memories/tidy/dry-run(writeAuth);面板「记忆」页加试算卡(点了才跑、无轮询、三态分开、明写「仅试算,不会改动任何记忆」)
- 真机 e2e(741 条活库副本):76 ms / 15 分区 / 53 候选 / 41 skipped / 1 vetoed(low_confidence);样例 merge reason=fact_subset score=1.0000、link reason=similar_but_not_contained score=0.9248
- 变异:dry-run 顺手写库 ⇒ 两条测试红(行数 0→2);自造阈值 ⇒ 红;失败回 200 空数组 ⇒ 红
- 如实登记:Compared=688 被 400/分区护栏截断、候选上限 20/分区被打满 ⇒ 护栏值需在 741 条量级重定(非 bug)
2026-10-05 22:06:54 +08:00

127 lines
5.6 KiB
Go

package memory
import (
"context"
"errors"
"fmt"
)
// 本文件是记忆整理的**试算(dry-run)**入口(契约 v0.75;切片 MT1)。
//
// 它与低峰心跳跑的是**同一条**规则路径(扫描 → 召回 → 包含判定 → 规则否决 →
// 构造候选),只有两点刻意不同:
//
// 1. **零副作用**:候选不落库 —— 不写 memory_tidy_candidates、不写
// memory_tidy_runs、**绝不改 memory_items 的任何列**。这是 dry-run 的命门,
// 因此它连运行留痕都不写(留痕本身也是一次写入)。
// 2. **召回不降级**:语义召回(embedding)不可用时**如实报错**,绝不静默回退到
// 词项余弦 —— 否则面板上的「没有候选」会是一句谎话(那是「没算出来」,不是
// 「算过了,没有」)。生产轮的降级口径见 tidy.go 的 fallbackRecall。
//
// 它**不**受低峰窗口约束:窗口是心跳的调度口径(design/23 §4),不是规则的
// 一部分 —— 「现在就想看看它会建议什么」正是这个入口存在的理由。
// ErrTidyDisabled 在整理被配置显式关闭(MEMORY_TIDY_ENABLED=false)却又被要求
// 试算时返回。它与「未接线」不同:这是配置选择,必须如实说出来,而不是回一份
// 空候选假装「没有建议」。
var ErrTidyDisabled = errors.New("memory/tidy: disabled by configuration")
// ErrTidyRecallUnavailable 在**试算**要求语义召回、而 embedding 不可用时返回。
// 生产轮此时会降级到词项余弦;试算轮刻意不降级(见本文件头注释)。
var ErrTidyRecallUnavailable = errors.New("memory/tidy: recall embedder unavailable")
// TidyDryRun 是一次试算的结果。Candidates 与落库候选**同构**(同一套
// buildCandidates 产物),但没有 status/decided 字段 —— 试算不落库,因此不存在
// 「已决定」这回事。
type TidyDryRun struct {
// Stats 是本轮的观测口径(扫描/比对/召回/否决分布),与真跑同一套计数。
Stats TidyStats
// Candidates 是本轮产出的候选(每个分区已套用 CandidateLimit)。
Candidates []TidyCandidate
// RecallMode 记录相似度来源(embedding / lexical),供面板与运维判读。
// 严格模式下一次试算只可能有一种来源:要么语义召回成功,要么如实报错。
RecallMode string
// DurationMS 是本轮试算的墙钟耗时(毫秒),供面板显示「跑一次要多久」。
DurationMS int64
}
// DryRun 跑一轮整理的**规则**并返回候选,但零副作用(见本文件头注释)。
//
// 与 RunOnce 的失败口径一致:单个分区失败不阻塞其它分区;只要有分区失败就回
// 错误(此时 Candidates 可能只含已完成分区的部分结果,调用方**不得**把它当
// 「完整的建议」展示)。
func (t *Tidier) DryRun(ctx context.Context) (TidyDryRun, error) {
started := t.now()
out := TidyDryRun{RecallMode: tidyRecallMode(t.deps.Embedder != nil)}
stats, partitions, err := t.beginRound(ctx)
if err != nil {
return out, err
}
// 收集出口:闭包只在 scanPartitions 的**串行**循环里被调用,因此无需加锁。
collected := make([]TidyCandidate, 0)
policy := tidyRoundPolicy{
sink: func(_ context.Context, cands []TidyCandidate, _ *TidyStats) {
collected = append(collected, cands...)
},
recall: t.strictRecall,
}
failures := t.scanPartitions(ctx, partitions, &stats, policy)
out.Stats = stats
out.Candidates = collected
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...))
}
t.logDryRun(out)
return out, nil
}
// strictRecall 是**试算轮**的召回:语义召回不可用时**不**降级,如实上报。
// 日志只记分区与错误,绝不带记忆正文(候选预览里已含正文)。
func (t *Tidier) strictRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats) ([]tidyPair, error) {
pairs, err := t.recallPairs(ctx, p, items, stats)
if err != nil {
t.log.Warn("memory tidy dry-run recall unavailable",
"partition", p.label(), "items", len(items), "error", err)
return nil, fmt.Errorf("%w: %w", ErrTidyRecallUnavailable, err)
}
return pairs, nil
}
// logDryRun 输出试算的观测口径。它带 source(memory/tidy),**不带任何记忆
// 正文** —— 只记计数、分区数与截断标签。
func (t *Tidier) logDryRun(out TidyDryRun) {
t.log.Info("memory tidy dry-run finished",
"rule_version", TidyRuleVersion,
"partitions", out.Stats.Partitions,
"scanned", out.Stats.Scanned,
"compared", out.Stats.Compared,
"pairs", out.Stats.Pairs,
"candidates", len(out.Candidates),
"vetoed", out.Stats.Vetoed,
"veto_reasons", tidyReasonsLabel(out.Stats.VetoReasons),
"errors", out.Stats.Errors,
"recall_mode", out.RecallMode,
"duration_ms", out.DurationMS)
}
// DryRun 立即跑一次试算(契约 v0.75;切片 MT1)。它复用与心跳、手动轮**同一把**
// runMu:试算与真跑串行,因此「一边试算一边落候选」不可能发生(design/23 §9
// 单写者)。试算自身零写入。
//
// 配置关闭(MEMORY_TIDY_ENABLED=false)时如实返回 ErrTidyDisabled:关闭是显式
// 选择,试算不绕过它,也不回一份空候选假装「没有建议」。
func (s *TidyService) DryRun(ctx context.Context) (TidyDryRun, error) {
if !s.cfg.Enabled {
return TidyDryRun{}, ErrTidyDisabled
}
if ctx == nil {
ctx = context.Background()
}
s.runMu.Lock()
defer s.runMu.Unlock()
return s.tidier.DryRun(ctx)
}