feat(memory): dedupe_group 候选动作 + 留存闸组合口径与 N6/N7 时点规则(M34)
M27 体检(DevDocs/audit/记忆库价值体检-2026-10-24.md)两条根治项:
1) 新增 `dedupe_group` 候选动作(不放大 containNone):
- 簇只认「同 scope+kind+完全相同 tag 集」或「同 scope+kind+前 12 字相同」
(后者附加词项余弦 ≥0.70 与同 kind 收窄);
- 每簇保留超集:内容最长 > merge_count 大 > created_at 早 > id;
- 安全门与既有合并路径同源:受限来源/绝对时刻/时效状态/低置信一律不进簇,
数值与否定与存活行不一致的成员被逐条剔出;临时层行(expires_at 非空)
两个方向都不进簇(存活行被清扫会给被吸收行留悬空 merged_into);
- 已被 archive/merge 候选覆盖的条目不重复出第二条待办(抑制);
- 落库形态与 merge 同构:source_ids 多条 + target_id,应用为整簇原子的软合并,
回滚复用 revertTidyMerge(逐条复原);
- 批量应用按**行**计上限 200 行/批(maxTidyApplyRowsPerBatch),超限如实 truncated;
- 不进自动档(人工/批量点);试算(DryRun)零写库给出簇数与可减少行数。
2) 留存闸改组合口径(治根)+ N6/N7:
- 护栏②从「含习惯/约定词就一票放行」改为「锚点词 **且** 含具体约束
(数字/禁忌/频次规律)」;无约束的锚点交给更窄的规则判;
- N1/N3/N4/N5 这几条词项规则改为「无锚点时才开火」(`主人喜欢说晚安`
这类偏好不再因寒暄词被判临时);
- 新增 N6(指示性时点,句首前 12 字内)与 N7(进行中状态),安全阀:习惯词、
硬性长期事实词、数值/约束、寒暄/情绪词一律按回去;
- 命中即第五态 defer,`expires_at = 判定时刻 + tempTtlSec`(默认 7 天,
已落在 $DATA_DIR/config/memory-tidy.json,零新 .env 键);**只开新增侧**,
存量一条不补写(用例钉住)。
契约(加性,不改既有字段语义):tidy dry-run/run 回执加 dedupeGroups /
dedupeRowsReducible / dedupeTruncated;批量应用回执加 deduped / rowsAffected。
实测(生产库只读 mode=ro,active 3667):
- 闸:改前 2 条(0.05%) → 改后 89 条(2.43%):n6 58 / n7 29 / n1 2;
89 条里 30 条含锚点词,逐条人读均为「约定明天中午见」「约定下周留个午后」
这类到期即失效的安排(唯一可议:1 条把「中午互动的日常」一起带进临时层)。
- dedupe_group:281 簇 / 可减 594 行(16.2% of active);tag_set 265 / prefix12 16;
簇大小 2:170 3:48 4:27 5:10 6:10 7:7 8:2 9:2 10:1。
验收:go build ./... + go vet ./... + go test ./... 全绿。
This commit is contained in:
1 parent
210c8b999f
commit
6a3f3a329a
20 files changed
+2353
-403
No files matched your search
@@ -202,6 +202,9 @@ func (t *Tidier) logRun(stats TidyStats, run TidyRun, ledger *recallLedger) {
|
||||
"auto_reclassify", stats.AutoReclassified,
|
||||
"auto_eligible", stats.AutoEligible,
|
||||
"auto_truncated", stats.AutoTruncated,
|
||||
"dedupe_groups", stats.DedupeGroups,
|
||||
"dedupe_rows", stats.DedupeRows,
|
||||
"dedupe_truncated", stats.DedupeTruncated,
|
||||
}
|
||||
if len(stats.Truncated) > 0 {
|
||||
fields = append(fields, "truncated", strings.Join(stats.Truncated, ","))
|
||||
@@ -305,6 +308,31 @@ func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyS
|
||||
return fmt.Errorf("memory/tidy: recall partition %s: %w", p.label(), err)
|
||||
}
|
||||
|
||||
candidates := t.mergeCandidates(archives, pairs, stats)
|
||||
|
||||
// dedupe_group(2026-10-24):机械簇(同 tag 集 / 前 12 字相同)**不依赖相似度
|
||||
// 召回**,因此它跑在可比条目全集上(`comparable`)、**不受**比对截断影响 ——
|
||||
// 截断是给 O(n²) 的两两余弦用的护栏,不该让机械分簇跟着少看一半条目。
|
||||
//
|
||||
// 它排在包含判定**之后**是为了**抑制重复待办**:一对已经拿到 archive/merge
|
||||
// 候选的条目不再进簇 —— 否则同一对会同时出现「并入超集」与「并入簇存活行」
|
||||
// 两条候选,第二条应用时必然 skip(source_not_active),面板上看像 bug。
|
||||
// 覆盖面由前一步的**产物**决定(不是又一套判据),因此抑制是纯粹的减法。
|
||||
dedupe, dedupeStats := t.dedupeInPartition(ctx, p, comparable, candidates)
|
||||
stats.DedupeGroups += dedupeStats.Groups
|
||||
stats.DedupeRows += dedupeStats.Rows
|
||||
|
||||
// dedupe 候选追加在既有两类之后:**不改变**归档/合并在候选上限里的优先序
|
||||
// (那两类是既有的、被验证过的口径;新动作不许把它们挤出上限)。
|
||||
candidates = append(candidates, dedupe...)
|
||||
candidates = t.trimPartitionCandidates(p, candidates, len(dedupe), stats)
|
||||
policy.sink(ctx, candidates, stats)
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeCandidates 把包含判定的产物收敛成候选:归档建议在前,merge 在后;
|
||||
// 被否决的对进否决分布,互不包含的对进 Kept(**不产候选**,2026-10-07 口径)。
|
||||
func (t *Tidier) mergeCandidates(archives []TidyCandidate, pairs []tidyPair, stats *TidyStats) []TidyCandidate {
|
||||
candidates := archives
|
||||
for _, pr := range pairs {
|
||||
verdict := judgePair(pr.a, pr.b, pr.score)
|
||||
@@ -321,19 +349,39 @@ func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyS
|
||||
}
|
||||
candidates = append(candidates, buildCandidates(pr, verdict, t.now().UTC())...)
|
||||
}
|
||||
// 每分区候选上限(design §9)对**全部候选**生效(含归档建议),
|
||||
// 超出的只登记不入队,并记进 stats.Truncated(绝不静默)。
|
||||
if len(candidates) > t.cfg.CandidateLimit {
|
||||
// WARN 带总数/上限/分区名:候选被上限截断意味着面板看到的**少于**规则
|
||||
// 判出的,必须能从这里读出被丢掉了多少。
|
||||
t.log.Warn("memory tidy candidates truncated by per-partition limit",
|
||||
"partition", p.label(), "candidates", len(candidates), "limit", t.cfg.CandidateLimit)
|
||||
stats.Skipped += len(candidates) - t.cfg.CandidateLimit
|
||||
stats.Truncated = append(stats.Truncated, p.label()+"(candidates)")
|
||||
candidates = candidates[:t.cfg.CandidateLimit]
|
||||
return candidates
|
||||
}
|
||||
|
||||
// trimPartitionCandidates 施加每分区候选上限(design §9),并**如实登记**被丢掉的
|
||||
// 部分:总量进 stats.Skipped 与 Truncated 标签,其中属于 dedupe 段的那部分单独进
|
||||
// stats.DedupeTruncated(新动作被截断时面板必须看得到,不许「静默少做」)。
|
||||
//
|
||||
// dedupeCount 是本批候选**尾部**属于 dedupe 的条数(调用方把 dedupe 追加在最后)。
|
||||
func (t *Tidier) trimPartitionCandidates(p tidyPartition, candidates []TidyCandidate,
|
||||
dedupeCount int, stats *TidyStats) []TidyCandidate {
|
||||
limit := t.cfg.CandidateLimit
|
||||
if len(candidates) <= limit {
|
||||
return candidates
|
||||
}
|
||||
policy.sink(ctx, candidates, stats)
|
||||
return nil
|
||||
dedupeStart := len(candidates) - dedupeCount
|
||||
// 上限切在 dedupe 段之前 ⇒ 丢掉的**全部**是 dedupe 候选;切在段内 ⇒ 丢掉尾部。
|
||||
keptDedupe := 0
|
||||
if limit > dedupeStart {
|
||||
keptDedupe = limit - dedupeStart
|
||||
}
|
||||
if keptDedupe > dedupeCount {
|
||||
keptDedupe = dedupeCount
|
||||
}
|
||||
droppedDedupe := dedupeCount - keptDedupe
|
||||
stats.DedupeTruncated += droppedDedupe
|
||||
// WARN 带总数/上限/分区名:候选被上限截断意味着面板看到的**少于**规则判出的,
|
||||
// 必须能从这里读出被丢掉了多少。
|
||||
t.log.Warn("memory tidy candidates truncated by per-partition limit",
|
||||
"partition", p.label(), "candidates", len(candidates), "limit", limit,
|
||||
"dedupe_truncated", droppedDedupe)
|
||||
stats.Skipped += len(candidates) - limit
|
||||
stats.Truncated = append(stats.Truncated, p.label()+"(candidates)")
|
||||
return candidates[:limit]
|
||||
}
|
||||
|
||||
// classifyItems 把分区内条目分流成**可比对**与**归档候选**(跳过的不产出):
|
||||
|
||||
Reference in new issue
Block a user