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

@@ -0,0 +1,110 @@
package memory
import (
"context"
"database/sql"
"errors"
"time"
)
// 本文件是 `dedupe_group`(2026-10-24,M34)的**应用与回滚**执行面。
//
// 它与 merge 共用同一套语义(design/23 §8 的软合并):被吸收行
// `status='merged' + merged_into=<存活 id>`,存活行 `merged_from` 追加、`merge_count`
// 累加;**绝不改正文、绝不删行**。因此回滚直接复用 `revertTidyMerge`(它本来就按
// `SourceIDs` 逐条复原,不是为「一对一」写的)。
//
// 与单条 merge 的两点差别、以及它们的理由:
//
// 1. **一条候选吸收多行**(一簇 N 条)。整簇在**一个事务**里落,任何一条不在册
// ⇒ 整簇回滚(`errTidyItemNotActive`),不会出现「吞了一半」的中间态 ——
// 半吞的簇最坏:面板显示 applied,但被吞行里有一半还在 active 集合里,
// 回滚也无从判断该复原哪几条。
// 2. **按行计的分批上限**(`maxTidyApplyRowsPerBatch`)。一条候选动 N 行,若只按
// 「候选条数」限批,一次批量应用可能在一条命令里改上千行记忆 —— 这正是体检
// 报告 §7.2 要求分批的理由(「第 2 批必须分批 + 每批留 run_id」)。行预算在
// `ApplyTidyCandidates` 里统一执行,超限如实报 `truncated`。
//
// 🔴 **不进自动档**:`tidyAutoApplyEligible` 的 switch 只认 merge/archive/reclassify,
// 默认分支返回 false,因此 dedupe_group 永远只走人工/批量点(测试钉着这一条)。
// maxTidyApplyRowsPerBatch 是一次批量应用**最多改动的记忆行数**(dedupe_group 按
// 被吸收行计)。200 的来源是体检报告 §7 第 2 批的建议值:一簇平均 3~4 行,
// 200 行 ≈ 50 簇,一屏可核对;它比候选条数上限(100)**更严**的场合是「簇很大」,
// 这正是需要护栏的那种数据。
const maxTidyApplyRowsPerBatch = 200
// tidyApplyRowCost 返回一条候选**会改动的记忆行数**,供批量应用的按行预算使用。
// 只有 dedupe_group 是多行动作;其余三种动作恒为 1(merge/archive/reclassify 各碰一行)。
func tidyApplyRowCost(c TidyCandidate) int {
if c.Action == TidyDedupeGroup && len(c.SourceIDs) > 0 {
return len(c.SourceIDs)
}
return 1
}
// applyTidyDedupeGroup 把一簇被吸收行软合并进存活行。
//
// 前置校验与 merge 逐条同源:分区硬门、只读来源硬门、目标行必须存在且与候选同分区。
// 任何一条不通过都如实 skip(不假装做过),且**一行都不写**。
func (s *Store) applyTidyDedupeGroup(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time) (TidyApplyResult, error) {
base := TidyApplyResult{
CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID, AffectedIDs: []string{},
}
if len(cand.SourceIDs) == 0 || cand.TargetID == "" {
return skipTidyApply(base, tidySkipInvalidCandidate), nil
}
survivor, err := s.Get(ctx, cand.TargetID)
if errors.Is(err, ErrNotFound) {
return skipTidyApply(base, tidySkipSourceMissing), nil
}
if err != nil {
return base, err
}
if skip := tidyPartitionMismatch(cand, survivor); skip != "" {
return skipTidyApply(base, skip), nil
}
if nonOwnerSource(survivor.SourceConversation) {
// 只读来源(群聊/陌生人)绝不改写(design/23 §5.2-9/§6):纵深防御,
// 规则层本就把这类条目排除在分簇之外。
return skipTidyApply(base, tidySkipSourceRestricted), nil
}
absorbed := make([]MemoryItem, 0, len(cand.SourceIDs))
for _, id := range cand.SourceIDs {
if id == cand.TargetID {
return skipTidyApply(base, tidySkipInvalidCandidate), nil
}
it, gerr := s.Get(ctx, id)
if errors.Is(gerr, ErrNotFound) {
return skipTidyApply(base, tidySkipSourceMissing), nil
}
if gerr != nil {
return base, gerr
}
if skip := tidyPartitionMismatch(cand, it); skip != "" {
return skipTidyApply(base, skip), nil
}
if nonOwnerSource(it.SourceConversation) {
return skipTidyApply(base, tidySkipSourceRestricted), nil
}
absorbed = append(absorbed, it)
}
err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error {
for _, it := range absorbed {
if err := markTidyAbsorbed(ctx, tx, it.ID, survivor.ID, now); err != nil {
return err
}
// 逐条 bump:bumpSurvivor 在事务内**重读**存活行的 merge_count,
// 因此连续调用会把 N 条都记进 merged_from、把计数加 N 次。
if err := bumpSurvivor(ctx, tx, survivor, it, now); err != nil {
return err
}
}
return nil
})
affected := make([]string, 0, len(absorbed))
for _, it := range absorbed {
affected = append(affected, it.ID)
}
return finishTidyApply(base, affected, err, tidySkipSourceNotActive)
}