Files
Cyrene_v2/internal/runtime/memory/tidy_auto_reclassify_test.go
T
AskaEth f310173b4c
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): reclassify 纳入自动档(主人 2026-10-07「全自动挡吧」)
自动落地现在三类:merge(字面重复 ∨ 事实等价 ∧ ≥0.95)· archive(one_off_event
且不像习惯)· **reclassify(新增)**。reclassify 只改 `kind` 一列
(`reclassifyTidyItemSQL` 只写 kind+updated_at),**不动正文、不动作用域、不碰第二条行**。

判据(纯函数 `tidyAutoReclassifyKind`,四个条件全中才自动):
① 恰 1 个 source 且无 target;② 理由 = `kind_mismatch`;③ 建议 kind 合法;
④ **改前 kind 合法且 ≠ 建议值** —— ③④ 是 **revert 抓手**:没有它自动落地就不可回滚。
**边界**:只读 ProposedKind / KindA,**一概不读** ProposedContent / MergedContent /
ContentA/B / 分区 / 时间字段。

六条护栏逐条覆盖它:软动作(同一条 apply 路径)/ 每轮上限(三类同占一个池子,
默认 50 硬顶 500,混合时截断如实)/ 审计 `memory.tidy.apply`(`auto=true`,
`reclassified=%d` + stats + 日志 + run 审计,**只带计数与理由、不带正文**)/
**可 revert**(`revertTidyReclassify` 按 KindA 写回,且只在当前 kind 仍等于建议值时生效)/
幂等(候选条件更新 + 行 `AND status='active'` 双闸)/ 开关关闭 ⇒ 连查询都不发。
另补一道本片新增的门:**受限来源**(群聊/陌生人)与跨分区的 reclassify **skipped**
(原先只有 merge/archive 有这道门)。

验收(主会话复跑 + 亲自变异):整树 `go build ./...` + vet 绿;memory/gateway 两包
测试绿;我用 -overlay 禁掉「必须真的变了」那一格 ⇒ `…EligibilityBoundary` **3 条子例变红**
(改前 kind 缺失/非法/与建议值相同 ⇒ 都"没法回滚")。子代理另做 4 个变异(连正文一起写 /
摘受限来源门 / 去掉 reclassified 计数 / 不要求可回滚抓手)全红。

🔴 **未动的红线(一行未改)**:`archive` 的「习惯类不归档」守卫(design/23 §5.2-1)
—— `looksLikeHabit` / `tidyHabitMarkers` / `tidyReasonOneOffEvent` 零改动,
`TestTidyAutoNeverArchivesHabitMisjudgedAsOneOff` 仍绿。要连它也自动请主人明说。

⚠️ 未部署(要一次 build + restart);⚠️ design/23 的 §7「reclassify 交人工」与
§13 开放点 5「全部人工应用」**现已过期**,主会话同步。
⚠️ 已知遗留(需新授权):历史积压里指向**已被吸收行**的 reclassify 候选会每轮被选中
又永远 `source_not_active` 跳过,白占一次每轮名额;彻底解法是「永久不可用的候选自动
标 rejected」(新行为)。
2026-10-07 11:26:34 +08:00

476 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"
"slices"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件锁定轮末自动档对 **reclassify**(kind 误分类修正,design/23 §5.1 T0-6)的
// 覆盖 —— 2026-10-07 主人追加口径「全自动挡吧」。它只改分类列:正文一字不动、
// 可回滚(靠候选证据 KindA)、计入每轮上限、开关一关就只产建议、
// 受限来源/跨分区一律跳过、畸形候选带的正文一律不采纳。
// TestTidyAutoReclassifyKindMismatch 是判据 ⑨:kind 误分类的候选由轮末自动档直接
// 落地 —— **只改 kind 一列**,正文/状态/行数一字不动,并如实写审计与留痕。
//
// 本用例走的是**规则层真实的产出路径**(RunOnce):包含关系成立但 kind 不同 ⇒
// 1 条 merge(真子集,不在自动档判据内 ⇒ 留人工)+ 1 条 reclassify(自动落地)。
func TestTidyAutoReclassifyKindMismatch(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
bad := seedWorldEventItem(t, store, fixtureHabitShort, base)
seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
tr, rows := newAutoTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
fixtureHabitLong: simVector(0.95),
}})
stats, err := tr.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if stats.AutoApplied != 1 || stats.AutoReclassified != 1 {
t.Fatalf("自动档计数 = %+v, want applied/reclassified = 1/1", stats)
}
if stats.AutoMerged != 0 || stats.AutoArchived != 0 || stats.AutoTruncated {
t.Fatalf("自动档不该顺手动别的:%+v", stats)
}
after := loadTidyItemRow(t, store, bad.ID)
if after.kind != string(KindRelationship) {
t.Fatalf("分类没被修:kind = %q, want %q", after.kind, KindRelationship)
}
if after.content != fixtureHabitShort {
t.Fatalf("自动 reclassify 改了正文:%q(design:只改分类列)", after.content)
}
if after.status != tidyStatusActive || after.mergedInto != "" {
t.Fatalf("自动 reclassify 动了别的列:%+v", after)
}
if got := countMemoryItems(t, store); got != 2 {
t.Fatalf("行数 = %d, want 2(绝不物理删除)", got)
}
cands := allTidyCandidates(t, store, 10)
res := candidatesByAction(cands, TidyReclassify)
if len(res) != 1 || res[0].Status != tidyCandidateApplied {
t.Fatalf("reclassify 候选 = %+v, want 恰 1 条 applied(自动档落的)", res)
}
merges := candidatesByAction(cands, TidyMerge)
if len(merges) != 1 || merges[0].Status != tidyCandidatePending {
t.Fatalf("真子集 merge 候选 = %+v, want 仍 pending(自动档不许扩权)", merges)
}
auto := autoAuditRows(rows)
if len(auto) != 1 {
t.Fatalf("自动档审计 = %d 条, want 1(%+v)", len(auto), *rows)
}
if !strings.Contains(auto[0].result, "auto=true") ||
!strings.Contains(auto[0].result, "reclassified=1") ||
!strings.Contains(auto[0].result, tidyAutoReasonKindMismatch) {
t.Fatalf("审计 result = %q, want 含 auto=true / reclassified=1 / %s",
auto[0].result, tidyAutoReasonKindMismatch)
}
if strings.Contains(auto[0].result, fixtureHabitShort) {
t.Fatal("审计里不得出现记忆正文")
}
runs, err := store.ListTidyRuns(ctx, 1)
if err != nil {
t.Fatalf("ListTidyRuns: %v", err)
}
if len(runs) != 1 || runs[0].Applied != 1 {
t.Fatalf("运行留痕 = %+v, want applied=1(自动落地要进留痕)", runs)
}
}
// TestTidyAutoReclassifyRevertRestoresKind 是护栏「可 revert」对 reclassify 的覆盖:
// 自动落地之后必须能把 kind 复原(靠候选证据里的 KindA),正文全程一字不动。
func TestTidyAutoReclassifyRevertRestoresKind(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
row := seedWorldEventItem(t, store, fixtureWorkShort, base)
cand := autoReclassifyCandidate(row, KindRelationship, "", base)
insertAutoCandidateOrFail(t, store, cand)
before := loadTidyItemRow(t, store, row.ID)
batch, err := store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{Limit: 10})
if err != nil {
t.Fatalf("AutoApplyTidyCandidates: %v", err)
}
if batch.Applied != 1 || batch.Reclassified != 1 || batch.Skipped != 0 {
t.Fatalf("自动落地回执 = %+v, want applied/reclassified = 1/1", batch)
}
if got := loadTidyItemRow(t, store, row.ID); got.kind != string(KindRelationship) {
t.Fatalf("kind = %q, want %q", got.kind, KindRelationship)
}
res, err := store.RevertTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"})
if err != nil {
t.Fatalf("RevertTidyCandidate: %v", err)
}
if res.Status != TidyApplyApplied {
t.Fatalf("回滚结果 = %+v, want applied", res)
}
restored := loadTidyItemRow(t, store, row.ID)
if restored.kind != before.kind {
t.Fatalf("回滚后 kind = %q, want %q(靠候选证据 KindA 复原)", restored.kind, before.kind)
}
if restored.content != before.content || restored.status != before.status {
t.Fatalf("回滚动了别的列:%+v(before=%+v)", restored, before)
}
if got, err := store.GetTidyCandidate(ctx, cand.ID); err != nil || got.Status != tidyCandidatePending {
t.Fatalf("回滚后候选状态 = %q (err=%v), want pending", got.Status, err)
}
if got := countMemoryItems(t, store); got != 1 {
t.Fatalf("行数 = %d, want 1(回滚绝不删行)", got)
}
}
// TestTidyAutoReclassifyIsIdempotent 是护栏「幂等」对 reclassify 的覆盖:候选状态
// 的条件更新是执行闸 —— 第二轮(以及直接再 apply 一次)不产生第二次副作用。
func TestTidyAutoReclassifyIsIdempotent(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
row := seedWorldEventItem(t, store, fixtureWorkShort, base)
cand := autoReclassifyCandidate(row, KindRelationship, "", base)
insertAutoCandidateOrFail(t, store, cand)
first, err := store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{Limit: 10})
if err != nil {
t.Fatalf("第一次 AutoApplyTidyCandidates: %v", err)
}
if first.Applied != 1 {
t.Fatalf("第一次回执 = %+v, want applied=1", first)
}
settled := dumpTidyTable(t, store, "memory_items", memoryItemsColumns)
// 已 applied 的候选不再是 pending ⇒ 第二轮连看都不看它(EligibleTotal=0)。
second, err := store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{Limit: 10})
if err != nil {
t.Fatalf("第二次 AutoApplyTidyCandidates: %v", err)
}
if second.EligibleTotal != 0 || second.Applied != 0 {
t.Fatalf("第二次回执 = %+v, want 零命中零落地(候选已不在待定集)", second)
}
// 直接再 apply 同一条 ⇒ 条件更新没命中 ⇒ 如实报 already_decided,且**零副作用**。
out, err := store.ApplyTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"})
if err != nil {
t.Fatalf("重复 ApplyTidyCandidate: %v", err)
}
if out.Status != TidyApplySkipped || out.Reason != tidySkipAlreadyDecided {
t.Fatalf("重复应用结果 = %+v, want skipped/%s", out, tidySkipAlreadyDecided)
}
if got := dumpTidyTable(t, store, "memory_items", memoryItemsColumns); !slices.Equal(got, settled) {
t.Fatalf("重复应用产生了第二次副作用:\n got=%v\nwant=%v", got, settled)
}
}
// TestTidyAutoPerRoundLimitCountsReclassify 是护栏「每轮上限」对 reclassify 的覆盖:
// 三类动作**同占一个池子**,超限截断如实(EligibleTotal 是真总数),下一轮把剩下的
// 排干 —— 绝不静默少做,也绝不因为动作不同而另开一份配额。
func TestTidyAutoPerRoundLimitCountsReclassify(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
oneA := seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base, 0.9))
oneB := seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base.Add(time.Minute), 0.9))
reA := seedWorldEventItem(t, store, fixtureWorkShort, base.Add(2*time.Minute))
reB := seedWorldEventItem(t, store, fixtureWorkLong, base.Add(3*time.Minute))
// 顺序由 created_at 决定(最新在前):reclassify 两条排在 archive 两条之前。
for _, c := range []TidyCandidate{
autoArchiveCandidate(oneA, base),
autoArchiveCandidate(oneB, base),
autoReclassifyCandidate(reA, KindRelationship, "", base.Add(time.Minute)),
autoReclassifyCandidate(reB, KindRelationship, "", base.Add(time.Minute)),
} {
insertAutoCandidateOrFail(t, store, c)
}
first, err := store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{Limit: 2})
if err != nil {
t.Fatalf("第一轮 AutoApplyTidyCandidates: %v", err)
}
if first.EligibleTotal != 4 || first.Applied != 2 || !first.Truncated {
t.Fatalf("第一轮回执 = %+v, want eligible_total=4 applied=2 truncated=true", first)
}
if first.Reclassified != 2 || first.Archived != 0 {
t.Fatalf("第一轮动作分解 = %+v, want Reclassified=2(reclassify 也算进上限)", first)
}
if got := loadTidyItemRow(t, store, reA.ID); got.kind == string(KindWorldEvent) {
t.Fatalf("被算进上限的两条 reclassify 该落地:%+v", got)
}
if got := loadTidyItemRow(t, store, oneA.ID); got.status == tidyStatusArchived {
t.Fatalf("超出上限的 archive 不该被做:%+v", got)
}
second, err := store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{Limit: 2})
if err != nil {
t.Fatalf("第二轮 AutoApplyTidyCandidates: %v", err)
}
if second.EligibleTotal != 2 || second.Applied != 2 || second.Truncated {
t.Fatalf("第二轮回执 = %+v, want eligible_total=2 applied=2 truncated=false(下一轮排干)", second)
}
if second.Archived != 2 {
t.Fatalf("第二轮动作分解 = %+v, want Archived=2", second)
}
if got := countArchived(t, store, []string{oneA.ID, oneB.ID}); got != 2 {
t.Fatalf("两轮后归档 = %d, want 2", got)
}
}
// TestTidyAutoSkipsRestrictedAndForeignReclassify 钉住两类「不许自动做的事」对
// reclassify 同样成立:受限来源(群聊/陌生人,design §5.2-9/§6)与跨分区候选在
// **执行面**被拒 —— 候选行是落库数据,可能来自更早的规则版本或手工改动。
func TestTidyAutoSkipsRestrictedAndForeignReclassify(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
group := seedTidyItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindWorldEvent,
Content: fixtureHabitShort, Confidence: 0.9, CreatedAt: base,
SourceConversation: FormatSourceConversation("sess-telegram:group1", "msg-9"),
})
owner := seedWorldEventItem(t, store, fixtureWorkShort, base.Add(time.Minute))
restricted := autoReclassifyCandidate(group, KindRelationship, "", base)
foreign := autoReclassifyCandidate(owner, KindRelationship, "", base)
foreign.Scope = ScopeUser // 候选说 user 区,行在 persona 区
for _, c := range []TidyCandidate{restricted, foreign} {
insertAutoCandidateOrFail(t, store, c)
}
batch, err := store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{Limit: 10})
if err != nil {
t.Fatalf("AutoApplyTidyCandidates: %v", err)
}
if batch.EligibleTotal != 2 {
t.Fatalf("命中判据 = %d, want 2(两条都看似高置信)", batch.EligibleTotal)
}
if batch.Applied != 0 || batch.Skipped != 2 {
t.Fatalf("回执 = %+v, want applied=0 skipped=2(执行面的硬门拦住)", batch)
}
if got := loadTidyItemRow(t, store, group.ID); got.kind != string(KindWorldEvent) {
t.Fatalf("群聊来源被改了分类:%+v(design §5.2-9 红线)", got)
}
if got := loadTidyItemRow(t, store, owner.ID); got.kind != string(KindWorldEvent) {
t.Fatalf("跨分区候选改动了行:%+v", got)
}
}
// TestTidyAutoDisabledSkipsReclassify 是护栏「一键回到只产建议」对 reclassify 的
// 覆盖:开关关闭时它连候选都不碰,只产建议(与 merge/archive 同一口径)。
func TestTidyAutoDisabledSkipsReclassify(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
bad := seedWorldEventItem(t, store, fixtureHabitShort, base)
seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
cfg := tidyTestCfg(0.80)
cfg.DisableAutoApply = true
tr, rows := newAutoTestTidier(t, store, cfg, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
fixtureHabitLong: simVector(0.95),
}})
stats, err := tr.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if stats.AutoApplied != 0 || stats.AutoReclassified != 0 || stats.AutoEligible != 0 {
t.Fatalf("开关关闭却仍在自动落地:%+v", stats)
}
if got := loadTidyItemRow(t, store, bad.ID); got.kind != string(KindWorldEvent) {
t.Fatalf("开关关闭时分类被改了:%+v", got)
}
// 建议照旧产出(reclassify 候选在,只是等人点)。
res := candidatesByAction(allTidyCandidates(t, store, 10), TidyReclassify)
if len(res) != 1 || res[0].Status != tidyCandidatePending {
t.Fatalf("reclassify 候选 = %+v, want 恰 1 条 pending 待人工", res)
}
if len(autoAuditRows(rows)) != 0 {
t.Fatalf("开关关闭时不该写自动档审计:%+v", *rows)
}
}
// TestTidyAutoReclassifyNeverAdoptsProposedContent 是判据 ⑨ 的**边界**:reclassify
// 的授权范围只有「分类列」。候选里就算带着 proposed_content / merged_content 之类
// 的正文(畸形行、手工塞进来的行),自动档也只采纳它的分类 —— 正文一个字都不许写。
//
// ⚠️ 变异用例:把「自动落地时只改 kind」改成「连正文也写入」(例如让
// reclassifyTidyItem 顺手把候选的 ProposedContent 写进 content)⇒ 本用例 RED。
func TestTidyAutoReclassifyNeverAdoptsProposedContent(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
const strayContent = "候选想改成的正文(不许被采纳)"
const strayMerged = "候选想写成的合并后正文(不许被采纳)"
row := seedWorldEventItem(t, store, fixtureWorkShort, base)
cand := autoReclassifyCandidate(row, KindRelationship, strayContent, base)
cand.MergedContent = strayMerged
insertAutoCandidateOrFail(t, store, cand)
before := loadTidyItemRow(t, store, row.ID)
batch, err := store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{Limit: 10})
if err != nil {
t.Fatalf("AutoApplyTidyCandidates: %v", err)
}
if batch.Applied != 1 || batch.Reclassified != 1 {
t.Fatalf("回执 = %+v, want applied/reclassified = 1/1(动作本身照做)", batch)
}
after := loadTidyItemRow(t, store, row.ID)
if after.kind != string(KindRelationship) {
t.Fatalf("分类没被修:kind = %q, want %q", after.kind, KindRelationship)
}
if after.content != before.content {
t.Fatalf("自动落地把候选的正文写进了记忆:\n got=%q\nwant=%q(reclassify 无权改正文)",
after.content, before.content)
}
for _, poison := range []string{strayContent, strayMerged} {
if strings.Contains(after.content, poison) {
t.Fatalf("正文里出现了候选带的 %q —— 畸形候选的正文被采纳了", poison)
}
}
if after.status != before.status || after.mergedInto != before.mergedInto ||
after.mergedFrom != before.mergedFrom || after.mergeCount != before.mergeCount {
t.Fatalf("reclassify 动了分类以外的列:%+v(before=%+v)", after, before)
}
if got := countMemoryItems(t, store); got != 1 {
t.Fatalf("行数 = %d, want 1", got)
}
}
// TestTidyAutoReclassifyEligibilityBoundary 逐条钉住 reclassify 的**自动判据**本身
// (纯函数,零 IO):它只认「形状完整 + 理由 kind_mismatch + 建议分类合法 + 有可回滚
// 的改前 kind」四种条件同时成立的那些。判据在 tidy_auto.go 的 tidyAutoReclassifyKind。
//
// 逐条反例的意义:这几条任何一条被放宽,自动档就会开始动**拿不准**或**没法回滚**的行。
func TestTidyAutoReclassifyEligibilityBoundary(t *testing.T) {
t.Parallel()
valid := TidyCandidate{
ID: "cand", PersonaID: "persona.a", Scope: ScopePersona, Kind: KindWorldEvent,
Action: TidyReclassify, SourceIDs: []string{"item-1"}, Reason: tidyReasonKindMismatch,
Evidence: TidyEvidence{KindA: string(KindWorldEvent), ProposedKind: string(KindRelationship)},
}
cases := []struct {
name string
mut func(*TidyCandidate)
want bool
}{
{"形状完整 ⇒ 自动", func(*TidyCandidate) {}, true},
{"带了 proposed_content(畸形正文)仍只采纳分类 ⇒ 自动", func(c *TidyCandidate) {
c.ProposedContent = "候选想改的正文"
c.MergedContent = "候选想写的合并后正文"
}, true},
{"缺 source ⇒ 不自动", func(c *TidyCandidate) { c.SourceIDs = nil }, false},
{"两个 source ⇒ 不自动", func(c *TidyCandidate) {
c.SourceIDs = []string{"item-1", "item-2"}
}, false},
{"指着第二条行(target_id 非空)⇒ 不自动", func(c *TidyCandidate) {
c.TargetID = "item-2"
}, false},
{"理由不是 kind_mismatch ⇒ 不自动", func(c *TidyCandidate) {
c.Reason = "someone_else_reason"
}, false},
{"建议分类非法 ⇒ 不自动", func(c *TidyCandidate) {
c.Evidence.ProposedKind = "topic"
}, false},
{"建议分类为空 ⇒ 不自动", func(c *TidyCandidate) {
c.Evidence.ProposedKind = ""
}, false},
{"改前 kind 缺失(没法回滚)⇒ 不自动", func(c *TidyCandidate) {
c.Evidence.KindA = ""
}, false},
{"改前 kind 非法 ⇒ 不自动", func(c *TidyCandidate) {
c.Evidence.KindA = "topic"
}, false},
{"建议值与改前值相同(无事可做)⇒ 不自动", func(c *TidyCandidate) {
c.Evidence.ProposedKind = c.Evidence.KindA
}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c := valid
tc.mut(&c)
got, reason := tidyAutoApplyEligible(c)
if got != tc.want {
t.Fatalf("tidyAutoApplyEligible = %t (%q), want %t", got, reason, tc.want)
}
if got && reason != tidyAutoReasonKindMismatch {
t.Fatalf("自动理由 = %q, want %q", reason, tidyAutoReasonKindMismatch)
}
if !got && reason != "" {
t.Fatalf("不该自动的候选却带了理由 %q(审计会把它记成落地理由)", reason)
}
})
}
}
// TestTidyAutoReclassifyBeforeCompanionMerge 钉住现实里最常见的那条组合路径:规则层在
// **kind 不同**的一对上会同时产 1 条 merge + 1 条 reclassify(design/23 §12.1 P7),
// 两条都命中自动判据时必须在**同一轮**里都落地 —— 否则被吸收行先变成 'merged',那条
// reclassify 就永远卡在待定集里(每轮被重新选中、又永远跳过,白吃一次每轮上限)。
//
// 它同时是「reclassify 只改分类、merge 只软合并」两条护栏的合力验证:正文一字不动、
// 行数不变、没有候选被留在待定集里。
func TestTidyAutoReclassifyBeforeCompanionMerge(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
misclassified := seedWorldEventItem(t, store, fixtureDupText, base)
other := seedTidyItem(t, store, habitItem(fixtureDupText, base.Add(time.Minute), 0.9))
tr, _ := newAutoTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
fixtureDupText: {1, 0},
}})
stats, err := tr.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if stats.AutoApplied != 2 || stats.AutoMerged != 1 || stats.AutoReclassified != 1 {
t.Fatalf("自动档计数 = %+v, want applied/merged/reclassified = 2/1/1(两条都要落地)", stats)
}
if stats.AutoTruncated {
t.Fatalf("没打满上限不该报截断:%+v", stats)
}
for _, c := range allTidyCandidates(t, store, 10) {
if c.Status == tidyCandidatePending {
t.Fatalf("有候选被留在待定集里(每轮都会白吃一次上限):%+v", c)
}
}
absorbed := loadTidyItemRow(t, store, other.ID)
if absorbed.status != ingestStatusMerged || absorbed.mergedInto != misclassified.ID {
t.Fatalf("被吸收行 = %+v, want merged into %s", absorbed, misclassified.ID)
}
if absorbed.kind != string(KindWorldEvent) {
t.Fatalf("被吸收行的 kind = %q, want %q(reclassify 该在它被吸收之前落地)",
absorbed.kind, KindWorldEvent)
}
survivor := loadTidyItemRow(t, store, misclassified.ID)
if survivor.status != tidyStatusActive || survivor.content != fixtureDupText ||
absorbed.content != fixtureDupText {
t.Fatalf("正文/状态被动过:survivor=%+v absorbed=%+v", survivor, absorbed)
}
if got := countMemoryItems(t, store); got != 2 {
t.Fatalf("行数 = %d, want 2(绝不物理删除)", got)
}
}