r1 复审 p1-1(会毁数据):tag_set 簇的安全门只有数值与否定,**专有名词/引号取值不比**, 生产语料实测把 `用户的昵称是「叶酱」。` 并进 `主人的昵称是「喵喵七号」…` 的存活行, 而同库另有 4 行写着「喵喵七号已作废、应称叶酱」⇒ 一次 apply 丢掉当前事实。 修法(结构性,不是补丁): - 新增取值门 tidy_dedupe_values.go:被吸收行的**引号取值**(「」『』“”‘’")与 **拉丁 token**(≥2 字母,含 h3R3 这类混排)必须在存活行正文中一个不少; 方向单向(被吸收 ⊆ 存活),与软合并语义同向(存活行多出的取值不会丢)。 补齐 design/23 §5.1-3 要求的「专有名词 / 数值 / 否定词」三件套里缺的第一件。 - tag_set 簇也过相似度下限(dedupeGroupMinSimilarity 两档统一)。取值 0.10 而非更高: 语料实测 tag_set 合法改写(`用户可能需要提醒吃早饭…` 0.047、`叶酱有工作…` 0.109) 的余弦本来就在 0.05~0.4 —— 抬高它只会删掉合法去重,不会多挡住危险对。 实测(503 对):0.10→保留 98%、0.20→86%、0.30→63%、0.40→40%、0.50→20%。 - 回归用例:TestDedupeGroupRejectsSameTagDifferentValue(脱敏真实语料 + 同值对照组)、 TestDedupeGroupLatinTokenIsAlsoAValue、TestDedupeGroupNeverLosesAValue(簇级不变式)、 TestDedupeGroupTagSetAlsoNeedsSimilarityFloor。去掉取值门 ⇒ 前三条红;把 tag 相似度 归零 ⇒ 第四条红。 r1 复审 p2-1(数据安全):元信息查询失败时**该分区本轮不分簇**(旧实现降级成 nil map, `temporary` 恒 false ⇒ 绕开「临时行绝不进簇」红线);apply 侧补 `expires_at` 复核 (候选久置/生成后变临时 ⇒ 整簇 skip,tidySkipTemporaryRow)。用例: TestDedupeInPartitionSkipsWhenMetaUnavailable、TestTidyApplyDedupeGroupSkipsTemporaryRows。 r1 复审 p2-2(闸误杀被低报):补三类安全阀 ——「以后/从此/往后/今后」= 自此刻起永久成立、 「换成/变成/改成/搬进」= 状态已改变且新状态持续、N7 的进行中标记同样必须句首 12 字内; 「宿舍」进长期事实词表;顺手修 `<=` 的 off-by-one(改为 `<`)。 实测(生产库只读 mode=ro,active 3667): - dedupe:281 簇/594 行 → **255 簇/505 行(13.8%)**;tag_set 240 / prefix12 15; 取值门挡 76 对、相似度门挡 12 对;簇大小 2:155 3:48 4:26 5:8 6:7 7:4 8:2 9:2 18:1 20:2 (两个 20 行簇与 18 行簇的取值集合均为单一「喵喵七号」⇒ 大簇=同一事实的重写)。 - 闸:改前 2 (0.05%) → 改后 **75 (2.05%)**(n6 53 / n7 20 / n1 2);含锚点词的 27 条 逐条人读均为「约定明天中午见 / 下周留个午后」类到期即失效的安排。 - 达标代价:+目前/现在 当锚点 → 2.13%;再叠一次性事件动词表(曾/发来/分享…)→ 6.74% 且新增 172 条里 56 条是长期身份/里程碑(「i love you 文件」17 条、「海报就是助手本人」 10 条…)⇒ 仍到不了 8%,不许为凑区间放宽。 验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。
171 lines
7.3 KiB
Go
171 lines
7.3 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"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 逐条同源(分区硬门、只读来源硬门、行必须存在、**临时层不复核**),
|
||
// 全部在 loadDedupeGroupRows 里;任何一条不通过都如实 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, absorbed, skip, err := s.loadDedupeGroupRows(ctx, cand)
|
||
if err != nil {
|
||
return base, err
|
||
}
|
||
if skip != "" {
|
||
return skipTidyApply(base, skip), nil
|
||
}
|
||
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)
|
||
}
|
||
|
||
// loadDedupeGroupRows 读出存活行与被吸收行并做**全部前置校验**;skip 非空表示应如实
|
||
// 跳过(原因由它给出),此时两行都不可用。
|
||
//
|
||
// 四类校验,缺一不可:
|
||
// 1. **分区硬门**(§5.2-7/8):候选与行必须同 persona/scope/project;
|
||
// 2. **只读来源硬门**(§5.2-9、§6):群聊/陌生人来源绝不改写;
|
||
// 3. **临时层复核**(r1 复审 P2-1):`expires_at` 非空的行既不许当存活行(会被
|
||
// 物理删除,design/23 §19 ⇒ 被吸收行悬空)也不许当被吸收行(本来就要消失)——
|
||
// 候选从生成到被人点是过时候选,必须在落库前按主键再判一次;
|
||
// 4. 形状:source 不得等于 target。
|
||
func (s *Store) loadDedupeGroupRows(ctx context.Context, cand TidyCandidate) (MemoryItem, []MemoryItem, string, error) {
|
||
empty := MemoryItem{}
|
||
survivor, err := s.Get(ctx, cand.TargetID)
|
||
if errors.Is(err, ErrNotFound) {
|
||
return empty, nil, tidySkipSourceMissing, nil
|
||
}
|
||
if err != nil {
|
||
return empty, nil, "", err
|
||
}
|
||
if skip := dedupeRowGuard(cand, survivor); skip != "" {
|
||
return empty, nil, skip, nil
|
||
}
|
||
if temporary, terr := s.tidyItemTemporary(ctx, survivor.ID); terr != nil {
|
||
return empty, nil, "", terr
|
||
} else if temporary {
|
||
return empty, nil, tidySkipTemporaryRow, nil
|
||
}
|
||
absorbed := make([]MemoryItem, 0, len(cand.SourceIDs))
|
||
for _, id := range cand.SourceIDs {
|
||
if id == cand.TargetID {
|
||
return empty, nil, tidySkipInvalidCandidate, nil
|
||
}
|
||
it, gerr := s.Get(ctx, id)
|
||
if errors.Is(gerr, ErrNotFound) {
|
||
return empty, nil, tidySkipSourceMissing, nil
|
||
}
|
||
if gerr != nil {
|
||
return empty, nil, "", gerr
|
||
}
|
||
if skip := dedupeRowGuard(cand, it); skip != "" {
|
||
return empty, nil, skip, nil
|
||
}
|
||
if temporary, terr := s.tidyItemTemporary(ctx, it.ID); terr != nil {
|
||
return empty, nil, "", terr
|
||
} else if temporary {
|
||
return empty, nil, tidySkipTemporaryRow, nil
|
||
}
|
||
absorbed = append(absorbed, it)
|
||
}
|
||
return survivor, absorbed, "", nil
|
||
}
|
||
|
||
// dedupeRowGuard 是「这一行能不能被这条候选改动」的静态判据(分区 + 只读来源)。
|
||
func dedupeRowGuard(cand TidyCandidate, it MemoryItem) string {
|
||
if skip := tidyPartitionMismatch(cand, it); skip != "" {
|
||
return skip
|
||
}
|
||
if nonOwnerSource(it.SourceConversation) {
|
||
// 只读来源(群聊/陌生人)绝不改写(design/23 §5.2-9/§6):纵深防御,
|
||
// 规则层本就把这类条目排除在分簇之外。
|
||
return tidySkipSourceRestricted
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// tidyItemTemporarySQL 只读一行的 `expires_at`(临时层判据,design/03 §9.6)。
|
||
const tidyItemTemporarySQL = `SELECT expires_at FROM memory_items WHERE id = ?`
|
||
|
||
// tidyItemTemporary 报告一条记忆是否在**临时层**(`expires_at` 非空)。
|
||
//
|
||
// 它服务应用侧的**过时复核**:`MemoryItem` 的读侧 SELECT 不含这一列(该列只写不读),
|
||
// 因此候选生成时用分区级元信息判一次、真正落库前再按主键判一次 —— 两次都要,
|
||
// 因为「候选生成」与「人点 apply」之间隔着任意长的墙钟(r1 复审 P2-1)。
|
||
func (s *Store) tidyItemTemporary(ctx context.Context, id string) (bool, error) {
|
||
var expiresAt string
|
||
err := s.db.QueryRowContext(ctx, tidyItemTemporarySQL, id).Scan(&expiresAt)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return false, nil
|
||
}
|
||
if err != nil {
|
||
return false, fmt.Errorf("memory/tidy: read item expiry %s: %w", id, err)
|
||
}
|
||
return strings.TrimSpace(expiresAt) != "", nil
|
||
}
|