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) } }