Files
Cyrene_v2/internal/runtime/memory/tidy_dedupe.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

493 lines
20 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"
"sort"
"strings"
"time"
)
// 本文件是 `dedupe_group` 候选动作(2026-10-24,M34/M27)的**簇形成**与候选构造。
//
// 动因是 M27 只读体检的定量结论(`DevDocs/audit/记忆库价值体检-2026-10-24.md`):
// active 3657 条里可复用 ≈32%,而「近似冗余」≈50%;但整理层 10-14 起**一条候选都不产**
// —— 因为它的合并判据是**包含关系**(`judgeContainment`),而问题的形态是
// 「同一件事换了个说法」:`助手名为 Cyrene。` 与 `助手/人格名为 Cyrene。` 互不包含。
//
// 🔴 **为什么不放大 `containNone`**(体检报告 §9-1 的两条路里选了后一条):
// `containNone` 是一条**护栏**(防「冰美式 vs 红牛」这类同主题不同事实被合并),
// 放宽它等于把护栏拆掉。因此这里新增一个**更窄**的动作:它不改变任何一对的
// 包含判定,只在「簇」这个粒度上认两种**机械可判**的同一性:
//
// 1. **同 scope + 同 kind + 完全相同 tag 集**(tag 集非空,且每个 tag ≥2 字、
// 至少 2 个 tag —— 体检报告 §7 第 2 批的建议:排除 `('作息','工作')` 这类
// 过于通用的 tag 集,宁可少减也不误杀);
// 2. **前 12 个字相同**(`dedupeGroupPrefixRunes`,与体检报告 R2 同一口径),
// 并附加**相似度下限** `dedupeGroupPrefixSimilarity` 与**同 kind** 两条收窄:
// 前者挡住「同一开头、后半句讲了别的实体」的对(`主人喜欢用「喵」…` vs
// `主人喜欢用「汪」…` 这类同构不同事实),后者避免「合并顺手改了分类」——
// kind 的改动仍只走 reclassify 那条路(design/23 §5.1 T0-6)。
//
// 分区(persona_id + scope + project_id)是**硬门**:候选只在一个分区内形成,
// 跨 scope 的同事实(体检报告 §7 第 3 批,83 簇/421 行)**明确不做**(会动 persona
// 写权语义,塔已拍不做)。
//
// 每簇的**安全门**与既有合并路径**逐条同源**(不新造一套判据):
// - 群聊/陌生人来源 ⇒ 排除(design/23 §5.2-9、§6,纵向防御同 tidy_rules);
// - 含绝对日期/时刻 ⇒ 排除(那些走 archive,design/23 §5.1 T0-5);
// - 含有时限状态词(假期/期间/本周…)⇒ 排除(§5.2-1);
// - 置信 < tidyLowConfidenceThreshold ⇒ 排除(§5.2-6);
// - 数值 token 与存活行不一致 ⇒ 该成员不入簇(§5.2-2);
// - 否定标记与存活行不一致 ⇒ 该成员不入簇(§5.2-3「X vs 非X 是覆盖不是重复」)。
//
// 保留规则(体检报告 §7 第 2 批的同一条):**内容最长(超集) > merge_count 大 >
// created_at 早**,仍并列时按 id 字典序(确定性)。「最长」优先是因为簇里的冗余
// 大多是「把同一件事越写越全」,最长那条通常信息量最大;merge_count 大说明它已经
// 吸收过别的表述;两者都并列时留最早的那条(它是 first_seen,时间跨度落在它身上)。
const (
// TidyDedupeGroup 是「机械簇去重」的动作名(design/23 §10 的 action 枚举新增值)。
//
// 它与 `merge` 的差别只在**判据**(簇的同一性 vs 事实集合的包含),落库形态完全
// 相同:一簇一条候选,`source_ids` = 被吸收的行,`target_id` = 存活行,应用是
// **软合并**(被吸收行 → status='merged' + merged_into),因此**可逐条 revert**。
//
// 🔴 **不在自动档里**:一次点击会改多行,体检报告 §7.2 要求在环核对(先做 100 簇、
// 人读 20 簇再放开),因此它**只走人工/批量点**,不进 tidy_auto 的白名单。
TidyDedupeGroup TidyAction = "dedupe_group"
// tidyReasonDedupeGroup 是 dedupe_group 候选的理由标签(落 reason 列)。
tidyReasonDedupeGroup = "dedupe_group"
// dedupeGroupPrefixRunes 是「前 N 字相同」的 N(体检报告 R2 的实测口径)。
dedupeGroupPrefixRunes = 12
// dedupeGroupMinTagRunes / dedupeGroupMinTagCount 是 tag 集簇的门槛:
// 每个 tag 至少 2 个字、至少 2 个 tag。单字 tag(「喵」「住」)与单个 tag
// 太宽,簇会退化成「同主题就合并」——那正是体检报告 §7 第 2 批警告的形态。
dedupeGroupMinTagRunes = 2
dedupeGroupMinTagCount = 2
// dedupeGroupPrefixSimilarity 是前缀簇成员的**附加**相似度下限(词项余弦,
// 与召回的 lexicalSimilarity 同一个函数、同一个量纲)。
//
// 这条收窄是**必须的**:语料实测 871 对「前 12 字相同」的候选对里,相似度分布从
// 0.11 到 0.99 —— 最低那批是「同开头、不同事实」(`助手(persona)的说话风格…`
// vs `助手(persona)的象征花为蓝玫瑰…`,0.111),合并它们是**丢事实**。
// 按阈值看语料(同一批 871 对):
//
// ≥0.60 → 40% ≥0.70 → 14% ≥0.75 → 6% ≥0.80 → 2% ≥0.85 → 1%
//
// 0.70 取在这里:≥0.70 的 120 对里抽 25 对人读**全是同一句话的改写**,而 0.11~0.35
// 段全是不同事实 ⇒ 0.70 是「既能捞回改写、又不碰同开头不同事实」的那一档。
// 它比 tidy 的召回门(0.80)低是刻意的:召回门只是「值不值得看一眼」,而这里是
// 「要不要自动判同一簇」,两者风险不同 —— 但**两者都不是判定依据**(簇的同一性
// 由「前 12 字」给出),这条下限只是把明显不同的对排除掉。
dedupeGroupPrefixSimilarity = 0.70
)
// dedupe_group 的证据字段(进 TidyEvidence,供面板 diff 预览与运维判读)。
const (
// tidyDedupeRuleTagSet / tidyDedupeRulePrefix 是簇的判据名(可审计)。
tidyDedupeRuleTagSet = "tag_set"
tidyDedupeRulePrefix = "prefix12"
// 保留规则名(evidence.kept_reason):存活行为什么是它。
tidyDedupeKeepLongest = "longest_content"
tidyDedupeKeepMergeCount = "merge_count"
tidyDedupeKeepOldest = "oldest_created_at"
tidyDedupeKeepID = "id_order"
)
// tidyDedupeKey 是簇键:判据 + 键值。两者一起进 evidence,便于复核「为什么它们同簇」。
type tidyDedupeKey struct {
rule string
key string
}
// tidyDedupeCluster 是一个待落候选的簇:存活行 + 被吸收行。
type tidyDedupeCluster struct {
Rule string
Key string
Survivor MemoryItem
Absorbed []MemoryItem
// KeptReason 是存活行胜出的那条规则(evidence.kept_reason 的来源)。
KeptReason string
}
// tidyDedupeStats 是分区内 dedupe 分簇的观测口径(进 TidyStats)。
type tidyDedupeStats struct {
// Groups 是**真正产出候选**的簇数。
Groups int
// Rows 是这些簇**可减少的 active 行数**(被吸收行合计)——「预计减少行数」。
Rows int
}
// dedupeInPartition 是 runPartition 调用点:读 merge_count、把**已被既有候选覆盖**的
// 条目屏蔽掉,再分簇产候选。
//
// 屏蔽(covered)是「不产生第二条待办」的落点:一对已经拿到 archive/merge 候选的
// 条目不再进簇。它是纯粹的减法(覆盖集合由前一步的**产物**给出),因此不是第二套判据。
func (t *Tidier) dedupeInPartition(ctx context.Context, p tidyPartition, items []MemoryItem,
existing []TidyCandidate) ([]TidyCandidate, tidyDedupeStats) {
covered := make(map[string]struct{}, len(existing)*2)
for _, c := range existing {
for _, id := range c.SourceIDs {
covered[id] = struct{}{}
}
if c.TargetID != "" {
covered[c.TargetID] = struct{}{}
}
}
// merge_count 是存活行的**次要**判据(「内容最长」并列时才用它)。缺它时降级到
// created_at 仍能给出确定性的存活行,因此这里降级继续、但如实记 WARN。
meta, err := t.deps.Store.dedupeItemMeta(ctx, p)
if err != nil {
t.log.Warn("memory tidy dedupe item meta unavailable, clustering without merge_count/temporary flags",
"partition", p.label(), "error", err)
meta = nil
}
return t.dedupeCandidates(items, meta, covered, t.now().UTC())
}
// dedupeCandidates 在**一个分区内**把 items 分成机械簇并产候选。它是纯函数式的
// (只读入参、无 IO、无模型),因此可完全单测,也在试算轮与真跑轮**共用**
// (两条路径都从 runPartition 进来,见 tidy.go)。
func (t *Tidier) dedupeCandidates(items []MemoryItem, meta map[string]tidyDedupeMeta,
covered map[string]struct{}, now time.Time) ([]TidyCandidate, tidyDedupeStats) {
clusters := dedupeClusters(items, meta, covered)
if len(clusters) == 0 {
return nil, tidyDedupeStats{}
}
out := make([]TidyCandidate, 0, len(clusters))
stats := tidyDedupeStats{}
for _, cl := range clusters {
out = append(out, dedupeGroupCandidate(cl, now))
stats.Groups++
stats.Rows += len(cl.Absorbed)
}
return out, stats
}
// dedupeClusters 把条目分组、按确定性顺序认领成员、并为每簇选出存活行。
//
// 「每一条只进一个簇」是靠**全局认领集合**保证的(大簇优先、键字典序次之):
// 一条记忆同时出现在两个簇里会让两次应用互相打脸(第二次的 `AND status='active'`
// 条件更新如实跳过,但面板会看到一条永远 skip 的候选)。
func dedupeClusters(items []MemoryItem, meta map[string]tidyDedupeMeta, covered map[string]struct{}) []tidyDedupeCluster {
groups := dedupeGroupItems(items, meta, covered)
ordered := dedupeOrderedGroups(groups)
return claimDedupeClusters(items, meta, ordered)
}
// dedupeGroupItems 把可进簇的条目按簇键归组(键 → 成员下标)。
func dedupeGroupItems(items []MemoryItem, meta map[string]tidyDedupeMeta, covered map[string]struct{}) map[string]*tidyDedupeGroup {
groups := make(map[string]*tidyDedupeGroup)
for idx := range items {
if _, taken := covered[items[idx].ID]; taken {
continue
}
if meta[items[idx].ID].temporary {
// 临时层行不许进簇(见 tidyDedupeMeta.temporary 的说明)。
continue
}
if !dedupeGroupEligible(items[idx]) {
continue
}
for _, key := range dedupeClusterKeys(items[idx]) {
g := groups[key.key]
if g == nil {
g = &tidyDedupeGroup{rule: key.rule, key: key.key}
groups[key.key] = g
}
g.members = append(g.members, idx)
}
}
return groups
}
// dedupeOrderedGroups 只留下真正的簇(≥2 条)并按**确定性顺序**排好:大簇优先
// (可减行数最多的先落),同大小按 (key, rule) 字典序 —— 与 map 的随机遍历顺序无关,
// 因此同一批数据反复运行产出同一批候选(幂等键随之稳定)。
func dedupeOrderedGroups(groups map[string]*tidyDedupeGroup) []*tidyDedupeGroup {
ordered := make([]*tidyDedupeGroup, 0, len(groups))
for _, g := range groups {
if len(g.members) < 2 {
continue
}
ordered = append(ordered, g)
}
sort.Slice(ordered, func(i, j int) bool {
if len(ordered[i].members) != len(ordered[j].members) {
return len(ordered[i].members) > len(ordered[j].members)
}
if ordered[i].key != ordered[j].key {
return ordered[i].key < ordered[j].key
}
return ordered[i].rule < ordered[j].rule
})
return ordered
}
// claimDedupeClusters 逐簇认领成员并产簇:**每一条只进一个簇**(靠全局认领集合),
// 存活行与全部被吸收行一起认领 —— 存活行绝不能再当别簇的被吸收行。
func claimDedupeClusters(items []MemoryItem, meta map[string]tidyDedupeMeta,
ordered []*tidyDedupeGroup) []tidyDedupeCluster {
claimed := make(map[int]struct{}, len(items))
out := make([]tidyDedupeCluster, 0, len(ordered))
for _, g := range ordered {
free := make([]tidyDedupeMember, 0, len(g.members))
for _, idx := range g.members {
if _, taken := claimed[idx]; taken {
continue
}
free = append(free, tidyDedupeMember{item: items[idx], mergeCount: meta[items[idx].ID].mergeCount})
}
if len(free) < 2 {
continue
}
survivor, keptReason := pickDedupeSurvivor(free)
absorbed := dedupeAbsorbable(g.rule, survivor, free)
if len(absorbed) == 0 {
continue
}
keep := make(map[string]struct{}, len(absorbed)+1)
keep[survivor.item.ID] = struct{}{}
for _, it := range absorbed {
keep[it.item.ID] = struct{}{}
}
for _, idx := range g.members {
if _, ok := keep[items[idx].ID]; ok {
claimed[idx] = struct{}{}
}
}
out = append(out, tidyDedupeCluster{
Rule: g.rule, Key: g.key, Survivor: survivor.item, Absorbed: dedupeItems(absorbed),
KeptReason: keptReason,
})
}
return out
}
// tidyDedupeGroup 是内部用的分组累加器(键 → 成员下标)。
type tidyDedupeGroup struct {
rule string
key string
members []int
}
// dedupeGroupEligible 报告一条记忆能不能进机械簇。它是既有硬门/否决规则的**并集**,
// 因此 dedupe_group 的判定面严格窄于 `judgePair`(后者还要求包含关系)。
func dedupeGroupEligible(it MemoryItem) bool {
if strings.TrimSpace(it.ID) == "" {
// 没有 id 的行不可能被候选引用(候选靠 source_ids / target_id 指向真实行),
// 把它放进簇只会产出一条永远 skip 的候选。纵深防御:这条在落库数据上不会出现。
return false
}
if nonOwnerSource(it.SourceConversation) {
// 群聊/陌生人来源只读不写(design/23 §5.2-9、§6):连簇都不进。
return false
}
if hasAbsoluteTimestamp(it.Content) {
// 一次性事件走 archive(§5.1 T0-5),不进合并。
return false
}
if hasTemporalState(it.Content) {
// 有期限状态(假期/期间/本周…)不是长期事实(§5.2-1)。
return false
}
if it.Confidence < tidyLowConfidenceThreshold {
// 低置信是**否决门**(§5.2-6):低置信条目的孪生被它挡住了 2479 对,
// 那正是体检报告 §6.2 的最大否决项。dedupe_group 不越过这道门。
return false
}
if len([]rune(it.Content)) < minTidyContentRunes {
return false
}
return true
}
// dedupeClusterKeys 返回一条记忆的簇键(tag 集 / 前 12 字)。两条判据都可能不成立
// (tag 集不合格 / 内容短于 12 字),那时只返回另一条。
func dedupeClusterKeys(it MemoryItem) []tidyDedupeKey {
out := make([]tidyDedupeKey, 0, 2)
if key := dedupeTagKey(it); key != "" {
out = append(out, tidyDedupeKey{rule: tidyDedupeRuleTagSet, key: key})
}
if key := dedupePrefixKey(it); key != "" {
out = append(out, tidyDedupeKey{rule: tidyDedupeRulePrefix, key: key})
}
return out
}
// dedupeTagKey 是「同分区 + 同 kind + 完全相同 tag 集」的键。tag 集为空/太通用时返回空串。
//
// 键里带**分区**(persona/scope/project)与 kind,两个都是**纵深防御**:
// - 调用方本来就按分区逐块调用(design/23 §6「分区之间永不比较」),但候选键
// 自带分区后,任何跨分区的输入(手工调用、将来别的入口)也**无法成簇** ——
// 跨 scope 收敛是塔已明确不做的那一批,它不许只靠调用纪律保证;
// - kind 是**内容类别**(relationship / world_event):跨 kind 的簇合并会顺手改掉
// 被吸收行的分类语义,而分类改动只该走 reclassify(§5.1 T0-6)。
func dedupeTagKey(it MemoryItem) string {
tags := normalizedTidyTags(it.Tags)
if len(tags) < dedupeGroupMinTagCount {
return ""
}
for _, tag := range tags {
if len([]rune(tag)) < dedupeGroupMinTagRunes {
return ""
}
}
return dedupePartitionPrefix(it) + string(it.Kind) + "\x1f" + strings.Join(tags, "\x1e")
}
// dedupePartitionPrefix 是簇键里的分区段(persona + scope + project)。
func dedupePartitionPrefix(it MemoryItem) string {
return it.PersonaID + "\x1e" + string(it.Scope) + "\x1e" + it.ProjectID + "\x1e"
}
// normalizedTidyTags 归一化 tag 集:去空白、去空项、去重、排序。排序是幂等键稳定
// 的前提(同一 tag 集的不同书写顺序必须落到同一个键)。
func normalizedTidyTags(tags []string) []string {
if len(tags) == 0 {
return nil
}
seen := make(map[string]struct{}, len(tags))
out := make([]string, 0, len(tags))
for _, tag := range tags {
t := strings.TrimSpace(tag)
if t == "" {
continue
}
if _, dup := seen[t]; dup {
continue
}
seen[t] = struct{}{}
out = append(out, t)
}
sort.Strings(out)
return out
}
// dedupePrefixKey 是「**同分区同 kind** + 前 12 个字相同」的键(按**归一化正文**取前 12 个 rune)。
//
// 归一化复用 dedupeKey(小写 + 空白折叠):`助手名为 Cyrene。` 与 `助手名为 Cyrene`
// 不该因为多一个空格就落到两个簇。
//
// 短于 12 字的内容返回空串**而不受理**:那时「前 12 字」等于全文,簇会退化成
// 「逐字重复」——那是 §15 D3-2 T0-1 的 `skip`(入库闸)与 `merge`(字面重复自动档)
// 已经在管的事,不需要第二个动作重复它。
func dedupePrefixKey(it MemoryItem) string {
runes := []rune(dedupeKey(it.Content))
if len(runes) < dedupeGroupPrefixRunes {
return ""
}
return dedupePartitionPrefix(it) + string(it.Kind) + "\x1f" + string(runes[:dedupeGroupPrefixRunes])
}
// tidyDedupeMember 是分簇用的条目视图:记忆行 + merge_count。
//
// 为什么要单独带 merge_count:`MemoryItem` 的读侧 SELECT(store.go 的 13 列)**不含**
// `merge_count`(它只写不读),而「存活行选谁」的次要判据正是它(合并次数多的那条
// 已经吸收过别的表述,留它信息量最大)。为一个次要判据去改 13 列读面会牵动
// Get/List*/preinsert/tidy 四处 SQL,风险远大于收益;改为按分区一次轻量查询把
// id → merge_count 取出来(见 Store.dedupeMergeCounts)。
type tidyDedupeMember struct {
item MemoryItem
mergeCount int
}
// dedupeItems 把成员视图降回 MemoryItem(簇只对外暴露记忆行)。
func dedupeItems(members []tidyDedupeMember) []MemoryItem {
out := make([]MemoryItem, 0, len(members))
for _, m := range members {
out = append(out, m.item)
}
return out
}
// pickDedupeSurvivor 按「内容最长 > merge_count 大 > created_at 早 > id 字典序」选存活行,
// 并返回命中的那条规则名(进 evidence.kept_reason,回答「为什么留下的是它」)。
func pickDedupeSurvivor(members []tidyDedupeMember) (tidyDedupeMember, string) {
best := members[0]
for _, it := range members[1:] {
switch compareDedupePriority(it, best) {
case -1:
best = it
case 0:
if it.item.ID < best.item.ID {
best = it
}
}
}
return best, dedupeKeepReason(members, best)
}
// compareDedupePriority 比较两条记忆谁更该存活:-1 表示 a 优先,1 表示 b 优先,
// 0 表示按 id 决胜(调用方处理)。
func compareDedupePriority(a, b tidyDedupeMember) int {
if la, lb := len([]rune(a.item.Content)), len([]rune(b.item.Content)); la != lb {
if la > lb {
return -1
}
return 1
}
if a.mergeCount != b.mergeCount {
if a.mergeCount > b.mergeCount {
return -1
}
return 1
}
if !a.item.CreatedAt.Equal(b.item.CreatedAt) {
if a.item.CreatedAt.Before(b.item.CreatedAt) {
return -1
}
return 1
}
return 0
}
// dedupeKeepReason 报告存活行是靠哪一条胜出的(证据面)。三条判据依次判「是否唯一
// 胜出」,都并列时落到最早创建(created_at)那条 —— 与 compareDedupePriority 同序。
func dedupeKeepReason(members []tidyDedupeMember, survivor tidyDedupeMember) string {
longest := len([]rune(survivor.item.Content))
uniqueLongest := true
uniqueMergeCount := true
for _, it := range members {
if it.item.ID == survivor.item.ID {
continue
}
if len([]rune(it.item.Content)) == longest {
uniqueLongest = false
}
if it.mergeCount == survivor.mergeCount {
uniqueMergeCount = false
}
}
switch {
case uniqueLongest:
return tidyDedupeKeepLongest
case uniqueMergeCount:
return tidyDedupeKeepMergeCount
case survivor.item.CreatedAt.Before(earliestOtherCreatedAt(members, survivor)):
return tidyDedupeKeepOldest
default:
return tidyDedupeKeepID
}
}
// earliestOtherCreatedAt 返回除存活行以外最早创建的时刻(用于区分「最早胜出」与
// 「id 决胜」这两种证据)。空簇成员只在单条簇里出现,那时调用方不会问证据。
func earliestOtherCreatedAt(members []tidyDedupeMember, survivor tidyDedupeMember) time.Time {
var earliest time.Time
for _, it := range members {
if it.item.ID == survivor.item.ID {
continue
}
if earliest.IsZero() || it.item.CreatedAt.Before(earliest) {
earliest = it.item.CreatedAt
}
}
return earliest
}