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)
This commit is contained in:
1 parent
3d8c30dc99
commit
d6ed9cfa2b
18 files changed
+1631
-79
No files matched your search
@@ -158,28 +158,39 @@ func NewTidier(cfg TidyConfig, deps TidyDeps) (*Tidier, error) {
|
||||
// Config 返回生效的整理配置(已套默认值)。
|
||||
func (t *Tidier) Config() TidyConfig { return t.cfg }
|
||||
|
||||
// tidyCandidateSink 是**本轮的候选出口**:一个分区比完就把候选交给它。
|
||||
// 生产轮是落库(persistCandidates),试算轮只收集(tidy_dryrun.go)——
|
||||
// 规则引擎本身与出口无关,这正是「禁止写第二套实现」的落点。
|
||||
type tidyCandidateSink func(ctx context.Context, cands []TidyCandidate, stats *TidyStats)
|
||||
|
||||
// tidyRecall 是**本轮的召回策略**:给定已截断的参与比对条目,产出候选对。
|
||||
// 它把「相似度来源不可用时怎么办」的差异收在一处:生产轮降级到零依赖词项
|
||||
// 余弦,试算轮如实上报错误。
|
||||
type tidyRecall func(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats) ([]tidyPair, error)
|
||||
|
||||
// tidyRoundPolicy 是本轮整理的出口策略:候选去哪 + 召回来源不可用时怎么办。
|
||||
// 「生产轮」与「试算轮」的**全部**差异都在这两个字段上,规则引擎其余部分
|
||||
// 逐字共用。
|
||||
type tidyRoundPolicy struct {
|
||||
sink tidyCandidateSink
|
||||
recall tidyRecall
|
||||
}
|
||||
|
||||
// RunOnce 跑一轮整理,并写一条运行留痕。它返回本轮的观测计数。
|
||||
//
|
||||
// 失败隔离(design/23 §9):单个分区失败不影响其它分区,失败计数进运行表与
|
||||
// 日志;只有「所有分区都失败」或运行留痕本身写不进去时才回错误。
|
||||
func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
|
||||
started := t.now().UTC()
|
||||
stats := TidyStats{VetoReasons: make(map[string]int)}
|
||||
|
||||
partitions, err := t.deps.Store.listTidyPartitions(ctx)
|
||||
stats, partitions, err := t.beginRound(ctx)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("memory/tidy: list partitions: %w", err)
|
||||
return stats, err
|
||||
}
|
||||
stats.Partitions = len(partitions)
|
||||
|
||||
var failures []error
|
||||
for _, p := range partitions {
|
||||
if err := t.runPartition(ctx, p, &stats); err != nil {
|
||||
stats.Errors++
|
||||
failures = append(failures, err)
|
||||
t.log.Warn("memory tidy partition failed", "partition", p.label(), "error", err)
|
||||
}
|
||||
}
|
||||
failures := t.scanPartitions(ctx, partitions, &stats, tidyRoundPolicy{
|
||||
sink: t.persistCandidates,
|
||||
recall: t.fallbackRecall,
|
||||
})
|
||||
|
||||
run := TidyRun{
|
||||
// 运行 id 在这里就定下来:留痕与日志必须指向同一条运行记录。
|
||||
@@ -236,12 +247,53 @@ func (t *Tidier) logRun(stats TidyStats, run TidyRun) {
|
||||
t.log.Info("memory tidy run finished", fields...)
|
||||
}
|
||||
|
||||
// beginRound 开一轮:准备口径容器并列出待整理分区。分区列表拿不到时整轮失败,
|
||||
// 且**不写运行留痕** —— 那一轮根本没开始(口径与 design §9 一致)。
|
||||
func (t *Tidier) beginRound(ctx context.Context) (TidyStats, []tidyPartition, error) {
|
||||
stats := TidyStats{VetoReasons: make(map[string]int)}
|
||||
partitions, err := t.deps.Store.listTidyPartitions(ctx)
|
||||
if err != nil {
|
||||
return stats, nil, fmt.Errorf("memory/tidy: list partitions: %w", err)
|
||||
}
|
||||
stats.Partitions = len(partitions)
|
||||
return stats, partitions, nil
|
||||
}
|
||||
|
||||
// scanPartitions 逐个分区跑规则并把候选交给 policy.sink。它返回失败列表
|
||||
// (单个分区失败不阻塞其它分区,design §9);成功时返回 nil。
|
||||
func (t *Tidier) scanPartitions(ctx context.Context, partitions []tidyPartition, stats *TidyStats, policy tidyRoundPolicy) []error {
|
||||
var failures []error
|
||||
for _, p := range partitions {
|
||||
if err := t.runPartition(ctx, p, stats, policy); err != nil {
|
||||
stats.Errors++
|
||||
failures = append(failures, err)
|
||||
t.log.Warn("memory tidy partition failed", "partition", p.label(), "error", err)
|
||||
}
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
// fallbackRecall 是**生产轮**的召回:相似度来源不可用时降级到零依赖的词项
|
||||
// 余弦(离线/无 embedding 配置时可跑,宁可比语义路径更保守),并记一条 WARN。
|
||||
// 两个来源**不可混用阈值**:阈值只有一个(design §13 开放点 2 的 0.80)。
|
||||
func (t *Tidier) fallbackRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats) ([]tidyPair, error) {
|
||||
pairs, err := t.recallPairs(ctx, p, items, stats)
|
||||
if err == nil {
|
||||
return pairs, nil
|
||||
}
|
||||
t.log.Warn("memory tidy embedding recall unavailable, falling back to lexical",
|
||||
"partition", p.label(), "error", err)
|
||||
return collectTidyPairs(items, t.cfg.RecallThreshold, lexicalSimFunc(items), tidyRecallLexical, stats), nil
|
||||
}
|
||||
|
||||
// runPartition 整理一个分区(persona_id + scope + project_id)。
|
||||
// 分区之间**永不比较**(design/23 §6 硬约束)。
|
||||
func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyStats) error {
|
||||
// 分区之间**永不比较**(design/23 §6 硬约束)。候选去处与召回降级策略都由
|
||||
// policy 决定,因此生产轮与试算轮走的是**同一条**扫描→召回→判定→构造候选
|
||||
// 的路径。
|
||||
func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyStats, policy tidyRoundPolicy) error {
|
||||
items, err := t.deps.Store.listTidyPartitionItems(ctx, p, t.cfg.ScanLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("memory/tidy: list partition %s: %w", p.label(), err)
|
||||
}
|
||||
stats.Scanned += len(items)
|
||||
if len(items) == t.cfg.ScanLimit {
|
||||
@@ -253,22 +305,15 @@ func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyS
|
||||
"partition", p.label(), "limit", t.cfg.ScanLimit)
|
||||
}
|
||||
|
||||
comparable := make([]MemoryItem, 0, len(items))
|
||||
archives := make([]TidyCandidate, 0)
|
||||
for _, it := range items {
|
||||
switch t.classifyForTidy(it, stats) {
|
||||
case tidySkip:
|
||||
continue
|
||||
case tidyArchive:
|
||||
// 一次性事件:产 archive 候选,且**不参与任何 merge**(design §5.1-5)。
|
||||
archives = append(archives, archiveCandidate(it, t.now().UTC()))
|
||||
continue
|
||||
default:
|
||||
comparable = append(comparable, it)
|
||||
}
|
||||
comparable, archives := t.classifyItems(items, stats)
|
||||
|
||||
// 截断与计数只做一次:召回策略拿到的是**已截断**的条目,回退重算不重复计数。
|
||||
compared := t.limitCompareItems(p, comparable, stats)
|
||||
pairs, err := policy.recall(ctx, p, compared, stats)
|
||||
if err != nil {
|
||||
return fmt.Errorf("memory/tidy: recall partition %s: %w", p.label(), err)
|
||||
}
|
||||
|
||||
pairs := t.recallPairs(ctx, p, comparable, stats)
|
||||
candidates := archives
|
||||
for _, pr := range pairs {
|
||||
verdict := judgePair(pr.a, pr.b, pr.score)
|
||||
@@ -286,10 +331,31 @@ func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyS
|
||||
stats.Truncated = append(stats.Truncated, p.label()+"(candidates)")
|
||||
candidates = candidates[:t.cfg.CandidateLimit]
|
||||
}
|
||||
t.persistCandidates(ctx, candidates, stats)
|
||||
policy.sink(ctx, candidates, stats)
|
||||
return nil
|
||||
}
|
||||
|
||||
// classifyItems 把分区内条目分流成**可比对**与**归档候选**(跳过的不产出):
|
||||
// - 群聊/陌生人来源 → 排除在扫描范围外(design/23 §6,只读不写);
|
||||
// - 含绝对日期/时刻 → 一次性事件,走归档而非合并([8]「9月8日晚22:38」),
|
||||
// 且**不参与任何 merge**(design §5.1-5);
|
||||
// - 过短内容 → 没有可判定的事实要素,跳过(不产噪声候选)。
|
||||
func (t *Tidier) classifyItems(items []MemoryItem, stats *TidyStats) (comparable []MemoryItem, archives []TidyCandidate) {
|
||||
comparable = make([]MemoryItem, 0, len(items))
|
||||
for _, it := range items {
|
||||
switch t.classifyForTidy(it, stats) {
|
||||
case tidySkip:
|
||||
continue
|
||||
case tidyArchive:
|
||||
archives = append(archives, archiveCandidate(it, t.now().UTC()))
|
||||
continue
|
||||
default:
|
||||
comparable = append(comparable, it)
|
||||
}
|
||||
}
|
||||
return comparable, archives
|
||||
}
|
||||
|
||||
// tidyDisposition 是扫描时一条记忆的处置分流。
|
||||
type tidyDisposition int
|
||||
|
||||
@@ -353,14 +419,6 @@ func tidyReasonsLabel(reasons map[string]int) string {
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// tidyRecallMode 报告本轮相似度的来源,便于运维判断阈值是否在预期口径上。
|
||||
func tidyRecallMode(hasEmbedder bool) string {
|
||||
if hasEmbedder {
|
||||
return "embedding"
|
||||
}
|
||||
return "lexical"
|
||||
}
|
||||
|
||||
// baseTidyLog 在 log 非 nil 时返回它,否则返回丢弃式日志器。
|
||||
func baseTidyLog(log *slog.Logger) *slog.Logger {
|
||||
return discardLogger(log)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||||
)
|
||||
|
||||
// 本文件锁定记忆整理「试算」(契约 v0.75;切片 MT1)的四条判据:
|
||||
//
|
||||
// ① **零副作用**:试算前后 memory_items / memory_tidy_candidates /
|
||||
// memory_tidy_runs **逐行不变**(这是 dry-run 的命门);
|
||||
// ② **真复用同一套规则**:同一批数据上,试算候选与真跑一轮落库的候选
|
||||
// 逐字段一致(不是另一套阈值实现);
|
||||
// ③ **尊重既有配置上限**:每分区候选上限(CandidateLimit)在试算里同样生效;
|
||||
// ④ **诚实错误**:语义召回不可用 / 整理被配置关闭时如实报错,绝不回空候选
|
||||
// 假装「没有建议」。
|
||||
|
||||
// failingEmbedder 模拟「语义召回不可用」(例如 llama.cpp :11434 不可达)。
|
||||
type failingEmbedder struct{ err error }
|
||||
|
||||
func (f failingEmbedder) Embed(context.Context, []string) ([]embedding.Vector, error) {
|
||||
return nil, f.err
|
||||
}
|
||||
|
||||
func (f failingEmbedder) Dim() int { return 8 }
|
||||
|
||||
// 三张相关表的**显式列清单**(不用 SELECT *):逐行快照按完整行内容比对,
|
||||
// 任何一列被改动都会让快照不等。
|
||||
var (
|
||||
memoryItemsColumns = []string{
|
||||
"id", "persona_id", "role_id", "scope", "kind", "content", "tags", "confidence",
|
||||
"source_conversation", "created_at", "updated_at", "decay_weight", "status",
|
||||
"merged_into", "merged_from", "merge_count", "occurred_at", "valid_to", "expires_at",
|
||||
}
|
||||
tidyCandidateColumns = []string{
|
||||
"id", "persona_id", "scope", "project_id", "kind", "action", "source_ids", "target_id",
|
||||
"proposed_content", "merged_content", "reason", "evidence", "rule_version",
|
||||
"score_min", "score_max", "status", "idempotency_key", "created_at", "decided_at", "decided_by",
|
||||
}
|
||||
tidyRunColumns = []string{
|
||||
"id", "started_at", "finished_at", "scanned", "candidates", "applied", "skipped",
|
||||
"errors", "vetoed", "veto_reasons", "rule_version",
|
||||
}
|
||||
)
|
||||
|
||||
// tidyTableSnapshot 是三张表在某一时刻的逐行内容。
|
||||
type tidyTableSnapshot struct {
|
||||
items []string
|
||||
candidates []string
|
||||
runs []string
|
||||
}
|
||||
|
||||
// dumpTidyTable 把一张表按主键序渲染成字符串行。
|
||||
func dumpTidyTable(t *testing.T, store *Store, table string, columns []string) []string {
|
||||
t.Helper()
|
||||
query := fmt.Sprintf("SELECT %s FROM %s ORDER BY id", strings.Join(columns, ", "), table)
|
||||
rows, err := store.db.QueryContext(context.Background(), query)
|
||||
if err != nil {
|
||||
t.Fatalf("dump %s: %v", table, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make([]string, 0)
|
||||
for rows.Next() {
|
||||
vals := make([]any, len(columns))
|
||||
ptrs := make([]any, len(columns))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
t.Fatalf("scan %s: %v", table, err)
|
||||
}
|
||||
parts := make([]string, 0, len(columns))
|
||||
for i, col := range columns {
|
||||
parts = append(parts, col+"="+formatCell(vals[i]))
|
||||
}
|
||||
out = append(out, strings.Join(parts, "|"))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("iterate %s: %v", table, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// formatCell 把 scan 出来的值渲染成稳定文本(TEXT 列以 []byte 出现)。
|
||||
func formatCell(v any) string {
|
||||
switch got := v.(type) {
|
||||
case nil:
|
||||
return "<nil>"
|
||||
case []byte:
|
||||
return string(got)
|
||||
default:
|
||||
return fmt.Sprintf("%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// snapshotTidyTables 拍一张三表快照。
|
||||
func snapshotTidyTables(t *testing.T, store *Store) tidyTableSnapshot {
|
||||
t.Helper()
|
||||
return tidyTableSnapshot{
|
||||
items: dumpTidyTable(t, store, "memory_items", memoryItemsColumns),
|
||||
candidates: dumpTidyTable(t, store, "memory_tidy_candidates", tidyCandidateColumns),
|
||||
runs: dumpTidyTable(t, store, "memory_tidy_runs", tidyRunColumns),
|
||||
}
|
||||
}
|
||||
|
||||
// assertRowsUnchanged 逐行比对快照:任何一行被新增/删除/改列都会失败。
|
||||
func assertRowsUnchanged(t *testing.T, what string, before, after []string) {
|
||||
t.Helper()
|
||||
if len(before) != len(after) {
|
||||
t.Fatalf("%s 行数 %d → %d,试算有副作用", what, len(before), len(after))
|
||||
}
|
||||
for i := range before {
|
||||
if before[i] != after[i] {
|
||||
t.Fatalf("%s 第 %d 行被改动:\n before=%s\n after =%s", what, i, before[i], after[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// candidateSignature 把一条候选渲染成与 id/落库时间无关的稳定签名,用于比对
|
||||
// 「试算候选」与「真跑一轮的候选」是否逐字段一致。
|
||||
func candidateSignature(c TidyCandidate) string {
|
||||
return fmt.Sprintf("%s|sources=%s|target=%s|kind=%s|reason=%s|proposed=%q|merged=%q|rule=%s|min=%.6f|max=%.6f",
|
||||
c.Action, strings.Join(c.SourceIDs, ","), c.TargetID, c.Kind, c.Reason,
|
||||
c.ProposedContent, c.MergedContent, c.RuleVersion, c.ScoreMin, c.ScoreMax)
|
||||
}
|
||||
|
||||
// sortedSignatures 返回按签名排序的候选集合(消除落库读回的排序差异)。
|
||||
func sortedSignatures(cands []TidyCandidate) []string {
|
||||
out := make([]string, 0, len(cands))
|
||||
for _, c := range cands {
|
||||
out = append(out, candidateSignature(c))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// seedDryRunFixture 落三条记忆:一对必合(短 ⊆ 长)+ 一条一次性事件(走 archive)。
|
||||
func seedDryRunFixture(t *testing.T, store *Store) {
|
||||
t.Helper()
|
||||
base := tidyTestTime(t)
|
||||
seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
|
||||
seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
|
||||
seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base.Add(2*time.Hour), 0.9))
|
||||
}
|
||||
|
||||
// dryRunFixtureEmbedder 让上述三条恰好产出一 merge + 一 archive。
|
||||
func dryRunFixtureEmbedder() stubEmbedder {
|
||||
return stubEmbedder{vecs: map[string]embedding.Vector{
|
||||
fixtureHabitShort: {1, 0},
|
||||
fixtureHabitLong: simVector(0.95),
|
||||
fixtureOneOffEvent: simVector(0.9),
|
||||
}}
|
||||
}
|
||||
|
||||
// TestTidyDryRun_ZeroSideEffects 是判据 ①:试算跑完之后,memory_items /
|
||||
// memory_tidy_candidates / memory_tidy_runs **逐行**与跑之前完全一致。
|
||||
func TestTidyDryRun_ZeroSideEffects(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedDryRunFixture(t, store)
|
||||
before := snapshotTidyTables(t, store)
|
||||
|
||||
tr := newTestTidier(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder())
|
||||
out, err := tr.DryRun(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DryRun: %v", err)
|
||||
}
|
||||
if len(out.Candidates) == 0 {
|
||||
t.Fatalf("夹具应当产出候选(否则零副作用断言是空转):%+v", out.Stats)
|
||||
}
|
||||
|
||||
assertRowsUnchanged(t, "memory_items", before.items, snapshotTidyTables(t, store).items)
|
||||
assertRowsUnchanged(t, "memory_tidy_candidates", before.candidates, snapshotTidyTables(t, store).candidates)
|
||||
assertRowsUnchanged(t, "memory_tidy_runs", before.runs, snapshotTidyTables(t, store).runs)
|
||||
}
|
||||
|
||||
// TestTidyDryRun_MatchesRealRunCandidates 是判据 ②:试算走的必须是**同一套规则**
|
||||
// —— 同一批数据上,试算候选与真跑一轮落库的候选逐字段一致。
|
||||
func TestTidyDryRun_MatchesRealRunCandidates(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedDryRunFixture(t, store)
|
||||
tr := newTestTidier(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder())
|
||||
|
||||
out, err := tr.DryRun(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DryRun: %v", err)
|
||||
}
|
||||
if len(out.Candidates) == 0 {
|
||||
t.Fatal("夹具应当产出候选")
|
||||
}
|
||||
if _, err := tr.RunOnce(ctx); err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
persisted, err := store.ListTidyCandidates(ctx, "", 100)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTidyCandidates: %v", err)
|
||||
}
|
||||
|
||||
want := sortedSignatures(out.Candidates)
|
||||
got := sortedSignatures(persisted)
|
||||
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||
t.Fatalf("试算候选与真跑一轮的候选不一致:\n dry=%v\n real=%v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyDryRun_RespectsCandidateLimit 是判据 ③:每分区候选上限(既有配置旋钮)
|
||||
// 在试算里同样生效,且超出的候选被如实登记(Skipped / Truncated),绝不静默。
|
||||
func TestTidyDryRun_RespectsCandidateLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedDryRunFixture(t, store)
|
||||
|
||||
cfg := tidyTestCfg(0.80)
|
||||
cfg.CandidateLimit = 1
|
||||
tr := newTestTidier(t, store, cfg, dryRunFixtureEmbedder())
|
||||
|
||||
out, err := tr.DryRun(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DryRun: %v", err)
|
||||
}
|
||||
if len(out.Candidates) != cfg.CandidateLimit {
|
||||
t.Fatalf("候选数 = %d, want %d(必须尊重既有每分区上限)", len(out.Candidates), cfg.CandidateLimit)
|
||||
}
|
||||
if out.Stats.Skipped != 1 {
|
||||
t.Fatalf("超限候选未被如实登记:skipped=%d, want 1", out.Stats.Skipped)
|
||||
}
|
||||
if len(out.Stats.Truncated) == 0 {
|
||||
t.Fatalf("候选上限命中必须留下截断痕迹:%+v", out.Stats)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyDryRun_EmbeddingUnavailableIsHonest 是判据 ④:语义召回不可用时**如实
|
||||
// 报错**(ErrTidyRecallUnavailable),绝不静默降级成「没有候选」;而生产轮在同样
|
||||
// 条件下依旧按既有口径回退到词项余弦(试算的严格性只属于试算)。
|
||||
func TestTidyDryRun_EmbeddingUnavailableIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedDryRunFixture(t, store)
|
||||
tr := newTestTidier(t, store, tidyTestCfg(0.80),
|
||||
failingEmbedder{err: errors.New("dial tcp 127.0.0.1:11434: connectex: 目标计算机积极拒绝")})
|
||||
|
||||
before := snapshotTidyTables(t, store)
|
||||
out, err := tr.DryRun(ctx)
|
||||
if !errors.Is(err, ErrTidyRecallUnavailable) {
|
||||
t.Fatalf("DryRun err = %v, want ErrTidyRecallUnavailable", err)
|
||||
}
|
||||
if len(out.Candidates) != 0 {
|
||||
t.Fatalf("召回不可用时不得产出候选:%+v", out.Candidates)
|
||||
}
|
||||
assertRowsUnchanged(t, "memory_tidy_candidates", before.candidates, snapshotTidyTables(t, store).candidates)
|
||||
assertRowsUnchanged(t, "memory_tidy_runs", before.runs, snapshotTidyTables(t, store).runs)
|
||||
|
||||
// 生产轮(RunOnce)不受试算的严格性影响:它照旧降级并跑完一轮。
|
||||
if _, err := tr.RunOnce(ctx); err != nil {
|
||||
t.Fatalf("生产轮应当回退到词项余弦并可跑完:%v", err)
|
||||
}
|
||||
runs, err := store.ListTidyRuns(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTidyRuns: %v", err)
|
||||
}
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("生产轮留痕 = %d 条, want 1(试算本身不写留痕)", len(runs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyDryRun_RecallModeMatchesEmbedder 钉住试算报出的召回来源与依赖一致。
|
||||
func TestTidyDryRun_RecallModeMatchesEmbedder(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedDryRunFixture(t, store)
|
||||
|
||||
withEmbedder := newTestTidier(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder())
|
||||
out, err := withEmbedder.DryRun(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DryRun(embedder): %v", err)
|
||||
}
|
||||
if out.RecallMode != tidyRecallEmbedding {
|
||||
t.Fatalf("recallMode = %q, want %q", out.RecallMode, tidyRecallEmbedding)
|
||||
}
|
||||
|
||||
withoutEmbedder := newTestTidier(t, store, tidyTestCfg(0.80), nil)
|
||||
out, err = withoutEmbedder.DryRun(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DryRun(lexical): %v", err)
|
||||
}
|
||||
if out.RecallMode != tidyRecallLexical {
|
||||
t.Fatalf("recallMode = %q, want %q", out.RecallMode, tidyRecallLexical)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyServiceDryRun_DisabledIsHonest 钉住「配置关闭 ⇒ 诚实错误」:关闭是显式
|
||||
// 选择,试算不绕过它,也不回一份空候选假装「没有建议」。
|
||||
func TestTidyServiceDryRun_DisabledIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedDryRunFixture(t, store)
|
||||
|
||||
cfg := tidyTestCfg(0.80)
|
||||
cfg.Enabled = false
|
||||
svc := newTidyServiceForTest(t, store, cfg, dryRunFixtureEmbedder(), time.Now, nil)
|
||||
if _, err := svc.DryRun(ctx); !errors.Is(err, ErrTidyDisabled) {
|
||||
t.Fatalf("DryRun err = %v, want ErrTidyDisabled", err)
|
||||
}
|
||||
runs, err := store.ListTidyRuns(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTidyRuns: %v", err)
|
||||
}
|
||||
if len(runs) != 0 {
|
||||
t.Fatalf("关闭时不得留下任何留痕:%+v", runs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyServiceDryRun_EnabledWritesNothing 是服务层的零副作用回归:开启状态下
|
||||
// 经服务入口试算同样一行都不写(连运行留痕都不写)。
|
||||
func TestTidyServiceDryRun_EnabledWritesNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedDryRunFixture(t, store)
|
||||
before := snapshotTidyTables(t, store)
|
||||
|
||||
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder(), time.Now, nil)
|
||||
out, err := svc.DryRun(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DryRun: %v", err)
|
||||
}
|
||||
if len(out.Candidates) == 0 {
|
||||
t.Fatal("夹具应当产出候选")
|
||||
}
|
||||
after := snapshotTidyTables(t, store)
|
||||
assertRowsUnchanged(t, "memory_items", before.items, after.items)
|
||||
assertRowsUnchanged(t, "memory_tidy_candidates", before.candidates, after.candidates)
|
||||
assertRowsUnchanged(t, "memory_tidy_runs", before.runs, after.runs)
|
||||
}
|
||||
@@ -15,6 +15,17 @@ import (
|
||||
// design/23 §1.2 的实据是「必合」的 [12]/[15]=0.8330 与「必不合」的
|
||||
// [3]/[6]=0.7100 只差 0.12,且全库最高相似度的一对(0.9319)恰是跨作用域
|
||||
// ⇒ 单阈值双向失效,判定必须靠包含判定 + 规则否决(tidy_rules.go)。
|
||||
//
|
||||
// 召回来源(embedding / 词项余弦)与「来源不可用时怎么办」被拆成两层:
|
||||
// recallPairs 只报事实(不可用就回错误),**回退还是上报由调用方的
|
||||
// tidyRecall 策略决定**(tidy.go 的生产轮回退、tidy_dryrun.go 的试算轮上报)。
|
||||
|
||||
const (
|
||||
// tidyRecallEmbedding 是本轮相似度来自语义 embedding。
|
||||
tidyRecallEmbedding = "embedding"
|
||||
// tidyRecallLexical 是本轮相似度来自零依赖的词项余弦。
|
||||
tidyRecallLexical = "lexical"
|
||||
)
|
||||
|
||||
// tidyPair 是一条被召回的候选对。
|
||||
type tidyPair struct {
|
||||
@@ -26,15 +37,35 @@ type tidyPair struct {
|
||||
|
||||
// recallPairs 在分区内召回候选对。条目少于 2 条时为空操作(无候选、不报错)。
|
||||
//
|
||||
// 两两比对是 O(n²·dim):设计稿每分区扫描上限 5000 条(design §9),但真正
|
||||
// 参与比对的最多 maxTidyCompareItems 条(最新优先),超限记 WARN 并登记到
|
||||
// stats.Truncated —— 截断绝不静默。
|
||||
// 相似度来源不可用时(embedder 报错、向量条数错位)返回错误 —— 回退决策不在
|
||||
// 这里:生产轮把它降级成词项余弦,试算轮如实上报(见 tidyRecall 策略)。
|
||||
//
|
||||
// 返回的对按「分数降序 + id」确定性排序,使每分区候选上限的截断可复现
|
||||
// (同一批数据两次运行产出同一批候选 ⇒ 幂等,design §4.1 A)。
|
||||
func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats) []tidyPair {
|
||||
func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats) ([]tidyPair, error) {
|
||||
if len(items) < 2 {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
if t.deps.Embedder == nil {
|
||||
return collectTidyPairs(items, t.cfg.RecallThreshold, lexicalSimFunc(items), tidyRecallLexical, stats), nil
|
||||
}
|
||||
vecs, err := t.embedItems(ctx, items)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(vecs) != len(items) {
|
||||
return nil, ErrTidyEmbedCount
|
||||
}
|
||||
return collectTidyPairs(items, t.cfg.RecallThreshold, vectorSimFunc(vecs), tidyRecallEmbedding, stats), nil
|
||||
}
|
||||
|
||||
// limitCompareItems 把参与比对的条目截断到性能护栏并计入 Compared。
|
||||
// 两两比对是 O(n²·dim):设计稿每分区扫描上限 5000 条(design §9),但真正参与
|
||||
// 比对的最多 maxTidyCompareItems 条(最新优先),超限记 WARN 并登记到
|
||||
// stats.Truncated —— 截断绝不静默。空/单条分区不计数(本来就没有对可比)。
|
||||
func (t *Tidier) limitCompareItems(p tidyPartition, items []MemoryItem, stats *TidyStats) []MemoryItem {
|
||||
if len(items) < 2 {
|
||||
return items
|
||||
}
|
||||
if len(items) > maxTidyCompareItems {
|
||||
stats.Truncated = append(stats.Truncated, p.label()+"(compare)")
|
||||
@@ -43,13 +74,17 @@ func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []Memor
|
||||
items = items[:maxTidyCompareItems]
|
||||
}
|
||||
stats.Compared += len(items)
|
||||
return items
|
||||
}
|
||||
|
||||
sims, mode := t.similarityFunc(ctx, p, items)
|
||||
// collectTidyPairs 是召回内核:把相似度 ≥ 阈值的对收集起来,按分数降序
|
||||
// (再按 id)确定性排序 —— 排序让「每分区候选上限」的截断可复现。
|
||||
func collectTidyPairs(items []MemoryItem, threshold float64, sim func(i, j int) float64, mode string, stats *TidyStats) []tidyPair {
|
||||
pairs := make([]tidyPair, 0)
|
||||
for i := 0; i < len(items); i++ {
|
||||
for j := i + 1; j < len(items); j++ {
|
||||
score := sims(i, j)
|
||||
if score < t.cfg.RecallThreshold {
|
||||
score := sim(i, j)
|
||||
if score < threshold {
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, tidyPair{a: items[i], b: items[j], score: score, mode: mode})
|
||||
@@ -68,31 +103,22 @@ func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []Memor
|
||||
return pairs
|
||||
}
|
||||
|
||||
// similarityFunc 返回分区内的两两相似度函数与来源标签。
|
||||
//
|
||||
// 接入 embedder 时用它批量向量化后取余弦(与检索侧同一口径:余弦夹到
|
||||
// [0,1])。embedder 缺席或整批 Embed 失败时回退到零依赖的词项余弦
|
||||
// —— 该回退保证整理在离线/无 embedding 配置时仍可跑(宁可比语义路径更保守),
|
||||
// 且让测试无需网络即可确定性运行。两个来源的相似度**不可混用阈值**:
|
||||
// 阈值只有一个(design §13 开放点 2 的 0.80),回退路径命中更少 ⇒ 更保守。
|
||||
func (t *Tidier) similarityFunc(ctx context.Context, p tidyPartition, items []MemoryItem) (func(i, j int) float64, string) {
|
||||
if t.deps.Embedder != nil {
|
||||
vecs, err := t.embedItems(ctx, items)
|
||||
if err == nil && len(vecs) == len(items) {
|
||||
return func(i, j int) float64 {
|
||||
return clampUnit(cosineSim(vecs[i], vecs[j]))
|
||||
}, "embedding"
|
||||
}
|
||||
t.log.Warn("memory tidy embedding recall unavailable, falling back to lexical",
|
||||
"partition", p.label(), "error", err)
|
||||
}
|
||||
// lexicalSimFunc 是零依赖的相似度函数:两条内容词项集合的余弦。
|
||||
func lexicalSimFunc(items []MemoryItem) func(i, j int) float64 {
|
||||
return func(i, j int) float64 {
|
||||
return lexicalSimilarity(items[i].Content, items[j].Content)
|
||||
}, "lexical"
|
||||
}
|
||||
}
|
||||
|
||||
// embedItems 分批把分区内条目向量化。单批失败即整体失败(调用方回退到
|
||||
// 词项余弦),不半途改用混合表示 —— 混用会让召回范围不可预测。
|
||||
// vectorSimFunc 是语义相似度函数(与检索侧同一口径:余弦夹到 [0,1])。
|
||||
func vectorSimFunc(vecs []embedding.Vector) func(i, j int) float64 {
|
||||
return func(i, j int) float64 {
|
||||
return clampUnit(cosineSim(vecs[i], vecs[j]))
|
||||
}
|
||||
}
|
||||
|
||||
// embedItems 分批把分区内条目向量化。单批失败即整体失败(调用方决定回退到
|
||||
// 词项余弦还是如实上报),不半途改用混合表示 —— 混用会让召回范围不可预测。
|
||||
func (t *Tidier) embedItems(ctx context.Context, items []MemoryItem) ([]embedding.Vector, error) {
|
||||
out := make([]embedding.Vector, 0, len(items))
|
||||
for start := 0; start < len(items); start += tidyEmbedBatch {
|
||||
@@ -142,3 +168,11 @@ func clampUnit(v float64) float64 {
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// tidyRecallMode 报告本轮相似度的来源,便于运维判断阈值是否在预期口径上。
|
||||
func tidyRecallMode(hasEmbedder bool) string {
|
||||
if hasEmbedder {
|
||||
return tidyRecallEmbedding
|
||||
}
|
||||
return tidyRecallLexical
|
||||
}
|
||||
Reference in new issue
Block a user