Files
Cyrene_v2/internal/runtime/memory/tidy_dedupe_candidate.go
T
AskaEth 01aff71bc1 fix(memory): dedupe_group 数值门改三态(归一后同值可合 / 同量纲不同值冲突 / 量纲不可比保守拒),修 r3 复审两项
r3 复审指出我第一版中文数字门两个方向都反了(规格本身被复审自己收回重写):

- 项 2(过严):归一后**同值**、中文与 ASCII 混写的两条合不了
  (`一小时` vs `60 分钟`、`十五分钟` vs `15 分钟`)。根因是判定仍以 `sameNumbers`
  (只抽 ASCII token)为闸:`{}` vs `{60}` 不等 ⇒ 直接否决,归一化没机会生效。
- 项 3(过松):**量纲不可比**(`一小时` vs `一杯`)被当成"不算冲突"⇒ 会合并,
  与「比不了单位就不许合并」相反。

修法:数值门从「布尔冲突」改成**三态**,并取代 `sameNumbers` 作为 dedupe 的数值判据:

| 结果 | 含义 | 合并 |
|---|---|---|
| tidyNumberSame | 归一后同值(含中英混写、单位换算:一小时 ≡ 60 分钟) | ✅ |
| tidyNumberConflict | 同量纲、值不相等(含单位不可互换:两杯 vs 三瓶) | ❌ |
| tidyNumberIncomparable | 量纲不可比 / 存活行没有同量纲数量可比(一小时 vs 一杯) | ❌ 保守拒绝 |

配套:
- 数量抽取同时支持 **ASCII 与中文数字 + 单位**(`60 分钟` / `15分钟` / `十五分钟` /
  `半个小时` / `一万两千行`),并新增 `字符`/`字节` 两个文字量单位;
- **裸数字 token(无单位)**仍按 `sameNumbers` 的集合相等口径判(`0023` vs `0024`
  这类靠它拦住),并有 parity 用例钉住「与 extractTidyFacts 的 nums 逐项一致」;
- 三态里 `incomparable` 只**收紧**,不放宽任何既有门;`sameNumbers` 函数本身未改
  (judgePair/judgeIngest 共用,另处 blast radius,已登记建议)。

回归用例(三态各一条 + 修前红/修后绿,均实测):
- TestDedupeGroupNumberGateThreeStates 四个子用例(两条同值含混写、同量纲不同值、量纲不可比),
  同时断言判定与簇结果;
  · 变异 = 退回 `sameNumbers`(项 2 旧行为)⇒ 两条「同值」子用例红;
  · 变异 = 不可比当放行(项 3 旧行为)⇒ 「量纲不可比」子用例红。
- TestDedupeGroupRejectsConflictingChineseNumbers / RejectsNonConvertibleUnits /
  SameChineseNumberStillMerges(r2 三项保持绿)+ TestTidyQuantitiesNumberParsing +
  TestScanTidyNumbersBareTokensMatchExistingFactNums(裸 token 口径不变)。

语料实测(生产库只读 mode=ro,active 3667,999 对已过否定/取值/相似度门的成员对):
三态分布 same=942 / conflict=25 / incomparable=32;dedupe 产出
`250 簇/495 行` → **248 簇/489 行(13.3%)**。incomparable 那 32 对是「四行歌词 vs
一段歌词」「一个项目进度代号 vs 项目进度代号为「w49」」这类:按「比不了就不许合并」
一律拒绝,聚合上只少减 6 行(簇成员互有重叠)。

验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。
2026-10-25 00:49:44 +08:00

110 lines
5.2 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 (
"sort"
"time"
)
// 本文件是 `dedupe_group` 的**候选构造**侧:从一簇成员里挑出可并入的行、
// 并把簇转成候选(落库形状与 merge 候选同构,因此面板/应用/回滚三条路都不用改)。
// 它从 tidy_dedupe.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行),
// 判据与注释**逐字未动**;簇形成在 tidy_dedupe.go,元信息查询在 tidy_dedupe_meta.go。
// dedupeAbsorbable 从一簇成员里挑出**可以并入存活行**的那些(逐条过 §5.2 的安全门)。
//
// 三道门都是对**存活行**比(不是两两全比):簇的语义是「这些行说的是同一件事」,
// 而同一性由存活行代表;任何一条与存活行不一致的成员,说明它讲的是**另一个事实**,
// 因此它**不进这一簇**(留待人工),而不是让整簇作废:
//
// 1. **数值**(§5.2-2,judgeDedupeNumbers):**三态**判据 ——
// 同值 ⇒ 可合并(中英混写也认:`一小时` ≡ `60 分钟`)/同量纲不同值 ⇒ 冲突/
// 量纲不可比 ⇒ 保守不许合并(`一小时` vs `一杯` 不是"放行")。
// 它取代了 `sameNumbers`(后者只认 ASCII token,既合不了中英混写、也看不见中文数字);
// 裸数字 token(无单位)仍按 `sameNumbers` 的**集合相等**口径判,一字未改。
// 沿革:r2(中文数字冲突)→ r3-项 2(归一后同值必须能合)→ r3-项 3(不可比必须拒)。
// 2. **否定**(§5.2-3,sameNegations):「X」vs「非X」是覆盖不是重复;
// 3. **取值**(§5.2-4/§5.2-5,dedupeValueGaps):被吸收行的引号取值 / 拉丁 token
// 必须在存活行里一个不少 —— 这条是 r1 复审 P1 的落点(`「叶酱」` 不许并进
// `「喵喵七号」`)。方向单向(被吸收 ⊆ 存活),与软合并语义同向。
//
// 相似度下限对**两种簇都生效**(`dedupeGroupMinSimilarity`):tag 集相同只是
// 「同主题」的弱证据(体检报告 §1.3 的「冰美式 vs 红牛」也同 tag 集),内容差得
// 太远的一律不进簇。「同开头」比「同 tag 集」更弱,因此它用的是更严的那一档。
func dedupeAbsorbable(rule string, survivor tidyDedupeMember, members []tidyDedupeMember) []tidyDedupeMember {
out := make([]tidyDedupeMember, 0, len(members))
for _, it := range members {
if it.item.ID == survivor.item.ID {
continue
}
if verdict, _ := judgeDedupeNumbers(it.item.Content, survivor.item.Content); verdict != tidyNumberSame {
continue
}
if !sameNegations(it.item.Content, survivor.item.Content) {
continue
}
if len(dedupeValueGaps(it.item.Content, survivor.item.Content)) > 0 {
continue
}
if lexicalSimilarity(it.item.Content, survivor.item.Content) < dedupeGroupMinSimilarity(rule) {
continue
}
out = append(out, it)
}
// 被吸收行的顺序也必须是确定的(幂等键按 sorted(source_ids) 算,但证据里的
// dedupe_members 会原样落库,顺序抖动会让同一个簇每次产出不同的证据文本)。
sort.Slice(out, func(i, j int) bool { return out[i].item.ID < out[j].item.ID })
return out
}
// dedupeGroupMinSimilarity 返回一种簇判据的相似度下限(词项余弦)。
//
// 两档刻意不同:「完全相同 tag 集」是结构化证据(同主题的强信号),容忍改写得远一点;
// 「前 12 字相同」只是「开头一样」,必须要求正文整体也相近,否则同开头不同事实
// (`助手(persona)的说话风格…` vs `助手(persona)的象征花…`)会被合掉。
func dedupeGroupMinSimilarity(rule string) float64 {
if rule == tidyDedupeRulePrefix {
return dedupeGroupPrefixSimilarity
}
return dedupeGroupTagSetSimilarity
}
// dedupeGroupCandidate 把一个簇转成候选。
//
// 落库字段与 merge 候选同构,因此既有面板/应用/回滚三条路都不用改形状:
// `source_ids` = 被吸收行(**可多条**,`applyTidyDedupeGroup` 逐条软合并),
// `target_id` = 存活行,`merged_content` = 存活行正文(**绝不改正文**)。
func dedupeGroupCandidate(cl tidyDedupeCluster, now time.Time) TidyCandidate {
absorbedIDs := make([]string, 0, len(cl.Absorbed))
members := make([]string, 0, len(cl.Absorbed))
for _, it := range cl.Absorbed {
absorbedIDs = append(absorbedIDs, it.ID)
members = append(members, it.Content)
}
return TidyCandidate{
ID: mustNewTidyID(),
PersonaID: cl.Survivor.PersonaID,
Scope: cl.Survivor.Scope,
ProjectID: cl.Survivor.ProjectID,
Kind: cl.Survivor.Kind,
Action: TidyDedupeGroup,
SourceIDs: absorbedIDs,
TargetID: cl.Survivor.ID,
ProposedContent: cl.Survivor.Content,
MergedContent: cl.Survivor.Content,
Reason: tidyReasonDedupeGroup,
Evidence: TidyEvidence{
Containment: "none",
ContentA: cl.Survivor.Content,
DedupeRule: cl.Rule,
DedupeKey: cl.Key,
ClusterSize: len(cl.Absorbed) + 1,
KeptReason: cl.KeptReason,
DedupeMembers: members,
KindA: string(cl.Survivor.Kind),
},
RuleVersion: TidyRuleVersion,
Status: tidyCandidatePending,
CreatedAt: now,
}
}