Files
AskaEth cc6904b1b4 feat(memory): retention_tier 档位列 + defer 与档位合并为同一判定(M37)
主人 2026-10-26 对「记忆分型」裁定「两列」:kind(关于什么)保留,新增
retention_tier(留多久 / 干什么用)。本片把这条轴从列、判定、写入、读侧过滤
一路打通,并把「判第五态 defer」与「判 retention_tier=temp」合并成一个判定。

结构(迁移 0026 + 自举前滚):
- memory_items 新增 retention_tier TEXT NOT NULL DEFAULT ''(空串 = 尚未分型,
  历史行与受限来源行),索引 idx_memory_items_retention_tier;up/down 均实跑过
  (在隔离的生产库副本上 migrate → 建 Store → down → 再 up,5073 行零丢失)。
- NewStore 幂等前滚该列 + 建索引(ensureRetentionSchema),与迁移同形由测试钉住。

判定(retention.go,全仓唯一入口):
- RetentionTier 闭集 temp/daily/work/long/knowledge + 空串未分型;与 kind 正交。
- resolveRetention 的优先级:调用方显式档位 > 显式有效期(⇒ temp)> 受限来源
  不分型 > 留存规则 N1~N7 ⇒ temp > 默认 long。判据靠元数据,规则不许推翻显式档位。
- tempDeadline 是「档位 → expires_at」的唯一换算:temp ⇔ 有效期非空,两列同一笔
  INSERT 落下(M2 登记过的「INSERT 与补写之间的短窗口」就此关闭)。

合并(defer ⇔ temp):
- judgeIngest 不再跑留存规则,只读 Add 落定的 item.RetentionTier;规则调用点因此
  全仓唯一(源码级断言钉住)。
- 判定不再依赖同步闸是否开启:闸未接/关着时档位与有效期照样落库。
- 异步层对已是 temp 的行不重复改写(避免 TTL 随审查延迟漂移)。
- IngestRuleVersion d3.3 → d3.4(判据作用范围变了,按既有硬规矩升版)。

读侧:
- 四条列表读路径的 SELECT 与 scanItem 读出 retention_tier(14 列);Get 同样读回。
- 新增 ListByPersonaTiers / ListScopeTiers / ListTiers / ListProjectTiers(可选档位
  过滤,缺省不过滤 ⇒ 历史行照常返回;显式传空串只取未分型;非法档位报错)。
  原方法签名逐字不变 —— 网关 MemoryStore 接口钉着它们,可变参数会编译不过。
- store.go 的读路径拆到 store_list.go,守 500 行上限。

回归:go build ./... + go vet ./... + go test ./... 全绿(内存包另有 -race)。
2026-10-26 13:09:30 +08:00

442 lines
19 KiB
Go
Raw Permalink 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
// dedupeGroupTagSetSimilarity 是 **tag 集簇**成员的相似度下限(r1 复审 P1 要求
// 「tag_set 簇也要过相似度下限,不能只有 prefix12 走这道门」)。
//
// ⚠️ 它取 **0.10**,比前缀簇那档(0.70)低一个量级,而且**刻意如此** ——
// 语料实测(active 3667、已过取值门/数值门/否定门的 503 对 tag 集成员对):
//
// floor 0.10 → 保留 98% floor 0.20 → 86% floor 0.30 → 63%
// floor 0.40 → 40% floor 0.50 → 20%
//
// 而**挡住的那 60% 全是同一事实的改写**,不是「不同事实」——例如
// `用户可能需要提醒吃早饭,容易空着肚子出门。`(0.047)对
// `早餐习惯:吃半碗炒面,需搭配牛奶或鸡蛋…`,或 `叶酱有工作,白天要上班…`(0.109)
// 对 `主人是上班族,工作日上班、单位有食堂…`。原因是语料里同一事实的多种写法
// 共享的是**语义**而不是二元组,词项余弦天然偏低。
//
// ⇒ 这道门的定位被数据改了:它只挡「正文几乎没有任何共同词项」的退化对
// (同 tag 却各说各话),**不是**同一性判据。同一性由三件套决定:数值门、
// 否定门、(P1 新增的)**取值门**。抬高这道门只会删掉合法去重、并不会多挡住
// 危险对 —— 需要更准的判据时该走 T1 模型层(不在本切片)。
dedupeGroupTagSetSimilarity = 0.10
)
// 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 + `expires_at` 是否非空)**不是可降级的旁支**:它承载的是
// 「临时层行绝不进簇」这条数据安全红线(临时行 7 天后会被后台物理删除,
// design/23 §19;让长期事实并进一个将被删除的存活行 = 信息不可达)。
//
// 因此查询失败时**这个分区这一轮不分簇**(fail-safe 的方向是「少做」而不是
// 「猜」):成本只是下一轮再来,而猜错的代价是不可逆的。
// 这是 r1 复审 P2-1 的落点 —— 旧实现降级成 nil map 后 `temporary` 恒为 false,
// 恰好把那道红线绕开了。
meta, err := t.deps.Store.dedupeItemMeta(ctx, p)
if err != nil {
t.log.Warn("memory tidy dedupe skipped: item meta unavailable",
"partition", p.label(), "error", err)
return nil, tidyDedupeStats{}
}
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
}
// tidyDedupeMember 是分簇用的条目视图:记忆行 + merge_count。
//
// 为什么要单独带 merge_count:`MemoryItem` 的读侧 SELECT(store_list.go 的 14 列)**不含**
// `merge_count`(它只写不读),而「存活行选谁」的次要判据正是它(合并次数多的那条
// 已经吸收过别的表述,留它信息量最大)。为一个次要判据去改 14 列读面会牵动
// 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
}