Files
Cyrene_v2/internal/runtime/memory/tidy_dedupe_apply.go
T
AskaEth 6a3f3a329a 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 ./... 全绿。
2026-10-25 00:08:46 +08:00

111 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}