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:
AskaEth committed 2026-10-25 00:08:46 +08:00
1 parent 210c8b999f
commit 6a3f3a329a
20 files changed
+2353 -403

No files matched your search

+60 -12
View File
@@ -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 把分区内条目分流成**可比对**与**归档候选**(跳过的不产出):