主人原话:「那么多 link 是干什么的?该删就删,怎么这个记忆整理机制这么保守呢」。
① **link 停产**(依据 design/23 §14 把它列为「未细化」⇒ 既无落点也无数据模型;
`applyable()` 也只认 merge/archive/reclassify ⇒ 应用 link 恒 `skipped`,是纯噪音):
- 规则层 `containNone` 判定改为 `keep`(不再产出 501 条不可应用的待办);
- `TidyLink` 常量保留,只为**读历史行**与「不可应用」判定;
- 存量出路:新端点 `POST /api/v1/admin/memories/candidates/reject?action=&limit=`
(**幂等、只改候选 status、不碰记忆**),主会话用它清掉存量 501 条。
② **轮末自动落地**(默认开,仅两类,判据是纯函数):
- `merge`:**字面重复** ∨「事实集合等价 ∧ 相似度 ≥ 0.95」;
- `archive`:理由 = `one_off_event` **且不像习惯**(`design/23 §5.2-1` 红线:
习惯类会因正文带钟点被误判成一次性事件,归档它是错的)。
- **不自动**:`reclassify`、低置信 merge、跨分区/跨角色、任何改正文、受限来源
(群聊/陌生人)—— 新增执行面守卫 `tidySkipSourceRestricted`。
③ **六条护栏**(缺一不可):软动作(复用既有 apply 路径)/ 每轮上限 50(硬顶 500,
超限如实 `Truncated` + 真总数)/ 审计 `memory.tidy.apply`(`auto=true`,不带正文)/
**可 revert**(复用既有回滚)/ 幂等(复用候选状态条件更新)/ **一键关回只产建议**
(`$DATA_DIR/config/memory-tidy.json` 的 `autoApply:false`,**零新 env 键**)。
验收(主会话复跑 + 亲自变异):memory 与 gateway 两包 build/vet/test 全绿;我用
-overlay 短路「习惯不归档」守卫 ⇒ TestTidyAutoNeverArchivesHabitMisjudgedAsOneOff
变红(逐字报「作息习惯被自动归档了 …design §5.2-1 红线」)。
⚠️ 未部署(要 build+restart 才生效);⚠️ 契约欠登记(新端点 B25 + §7.2),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
417 lines
16 KiB
Go
417 lines
16 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"math"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||
)
|
||
|
||
// 本文件是记忆整理(design/23 §12)的正/反例测试集:用设计稿引文脱敏构造的
|
||
// 夹具,逐条断言「该合的合、不该合的不合」。夹具取自 design/23 §1 的引文
|
||
// ([8] 一次性事件、[12]/[15] 假期状态、[1]/[3] 红牛 vs 冰美式、
|
||
// [21]/[25] 熬夜习惯的真子集),确保测的是设计稿点名的情形。
|
||
const (
|
||
fixtureHabitShort = "主人常在深夜与助手聊天,有熬夜习惯"
|
||
fixtureHabitLong = "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚"
|
||
fixtureHabitPlain = "主人常在深夜加班"
|
||
fixtureHabitVacation = "主人国庆假期期间常在深夜加班,处于休假状态"
|
||
fixtureEnergyRedBull = "主人喜欢喝红牛来提神,红牛是提神饮料"
|
||
fixtureEnergyIced = "主人喜欢喝冰美式来提神,冰美式是提神饮料"
|
||
fixtureOneOffEvent = "2026年9月8日周二晚22:38,主人仍在加班中度过的夜晚。"
|
||
fixtureWorkShort = "主人用喵打招呼"
|
||
fixtureWorkLong = "主人用喵打招呼,还会配合可爱的表情"
|
||
baseTidyTime = "2026-10-01T10:00:00.000000000Z"
|
||
)
|
||
|
||
// stubEmbedder 是按文本查表的确定性 embedder:测试用它精确控制「哪几对被
|
||
// 召回」,把**召回**与**判定**分开验(判定绝不看相似度)。未登记文本得到
|
||
// 一个与相似族正交的向量,因此不会意外参与召回。
|
||
type stubEmbedder struct {
|
||
vecs map[string]embedding.Vector
|
||
}
|
||
|
||
func (s stubEmbedder) Embed(_ context.Context, texts []string) ([]embedding.Vector, error) {
|
||
out := make([]embedding.Vector, 0, len(texts))
|
||
for _, text := range texts {
|
||
v, ok := s.vecs[text]
|
||
if !ok {
|
||
v = embedding.Vector{0, 1}
|
||
}
|
||
out = append(out, v)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s stubEmbedder) Dim() int { return 2 }
|
||
|
||
// simVector 返回与 {1,0} 余弦恰为 want 的向量(want ∈ (0,1])。
|
||
func simVector(want float64) embedding.Vector {
|
||
return embedding.Vector{float32(want), float32(math.Sqrt(1 - want*want))}
|
||
}
|
||
|
||
// tidyTestTime 把固定字符串解析成基准时刻。
|
||
func tidyTestTime(t *testing.T) time.Time {
|
||
t.Helper()
|
||
parsed, err := parseTime(baseTidyTime)
|
||
if err != nil {
|
||
t.Fatalf("parse base time: %v", err)
|
||
}
|
||
return parsed
|
||
}
|
||
|
||
// tidyTestCfg 是测试用的整理配置:只把召回门显式写死,其余走设计稿默认值。
|
||
func tidyTestCfg(threshold float64) TidyConfig {
|
||
return TidyConfig{
|
||
Enabled: true,
|
||
IntervalSec: defaultTidyIntervalSec,
|
||
QuietStartHour: defaultTidyQuietStartHour,
|
||
QuietEndHour: defaultTidyQuietEndHour,
|
||
RecallThreshold: threshold,
|
||
ScanLimit: 200,
|
||
CandidateLimit: defaultTidyCandidateLimit,
|
||
}
|
||
}
|
||
|
||
// newTestTidier 用给定存储、配置与可选 embedder 构建整理器。
|
||
func newTestTidier(t *testing.T, store *Store, cfg TidyConfig, emb embedding.Embedder) *Tidier {
|
||
t.Helper()
|
||
tr, err := NewTidier(cfg, TidyDeps{Store: store, Embedder: emb, Log: testLogger()})
|
||
if err != nil {
|
||
t.Fatalf("NewTidier: %v", err)
|
||
}
|
||
return tr
|
||
}
|
||
|
||
// seedTidyItem 落库一条测试记忆。
|
||
func seedTidyItem(t *testing.T, store *Store, item MemoryItem) MemoryItem {
|
||
t.Helper()
|
||
got, err := store.Add(context.Background(), item)
|
||
if err != nil {
|
||
t.Fatalf("seed item %q: %v", item.Content, err)
|
||
}
|
||
return got
|
||
}
|
||
|
||
// habitItem 是熬夜类记忆的构造器(同一分区:persona.a / persona / 无项目)。
|
||
func habitItem(content string, at time.Time, confidence float64) MemoryItem {
|
||
return MemoryItem{
|
||
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
|
||
Content: content, Confidence: confidence, CreatedAt: at, SourceConversation: FormatSourceConversation("web", "msg-1"),
|
||
}
|
||
}
|
||
|
||
// candidatesByAction 从候选列表里按动作筛选。
|
||
func candidatesByAction(cands []TidyCandidate, action TidyAction) []TidyCandidate {
|
||
out := make([]TidyCandidate, 0)
|
||
for _, c := range cands {
|
||
if c.Action == action {
|
||
out = append(out, c)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// allTidyCandidates 读一份**不分状态**的候选集合(pending + applied + rejected)。
|
||
//
|
||
// 2026-10-07 起 RunOnce 会跑**轮末自动档**:命中判据的候选(字面重复 / 事实等价 ∧
|
||
// ≥0.95 / 一次性事件且不像习惯)当场就被落地成 applied。因此「这一轮的规则产出了
|
||
// 什么候选」这类断言必须看全部状态 —— 只看待办队列会把「已经自动做完了」读成
|
||
// 「什么都没产出」。
|
||
func allTidyCandidates(t *testing.T, store *Store, limit int) []TidyCandidate {
|
||
t.Helper()
|
||
ctx := context.Background()
|
||
out := make([]TidyCandidate, 0)
|
||
for _, status := range []string{tidyCandidatePending, tidyCandidateApplied, tidyCandidateRejected} {
|
||
cands, err := store.ListTidyCandidates(ctx, status, limit)
|
||
if err != nil {
|
||
t.Fatalf("ListTidyCandidates(%s): %v", status, err)
|
||
}
|
||
out = append(out, cands...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// TestTidyContainedFactProducesMergeCandidate 是 design/23 §12.1 P1 的口径:
|
||
// A 的事实集合是 B 的子集 ⇒ 产 merge 候选,存活条目是超集 B,被并入的是 A。
|
||
func TestTidyContainedFactProducesMergeCandidate(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
short := seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
|
||
long := seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
|
||
|
||
tr := newTestTidier(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.Scanned != 2 || stats.Pairs != 1 {
|
||
t.Fatalf("stats scanned/pairs = %d/%d, want 2/1 (%+v)", stats.Scanned, stats.Pairs, stats)
|
||
}
|
||
|
||
cands, err := store.ListTidyCandidates(ctx, "", 50)
|
||
if err != nil {
|
||
t.Fatalf("ListTidyCandidates: %v", err)
|
||
}
|
||
merges := candidatesByAction(cands, TidyMerge)
|
||
if len(merges) != 1 {
|
||
t.Fatalf("merge candidates = %d, want 1 (%+v)", len(merges), cands)
|
||
}
|
||
got := merges[0]
|
||
if got.TargetID != long.ID {
|
||
t.Fatalf("merge target = %q, want 超集 B = %q", got.TargetID, long.ID)
|
||
}
|
||
if len(got.SourceIDs) != 1 || got.SourceIDs[0] != short.ID {
|
||
t.Fatalf("merge source ids = %v, want [%s]", got.SourceIDs, short.ID)
|
||
}
|
||
if got.MergedContent != fixtureHabitLong {
|
||
t.Fatalf("merged content = %q, want 超集原文", got.MergedContent)
|
||
}
|
||
if got.Evidence.Containment == string(containNone) || got.Evidence.Similarity < 0.80 {
|
||
t.Fatalf("evidence = %+v, want 包含判定为子集关系且带召回分数", got.Evidence)
|
||
}
|
||
if got.MergedContent != fixtureHabitLong || got.ProposedContent != fixtureHabitShort {
|
||
t.Fatalf("diff 预览的原文/合并结果不对:proposed=%q merged=%q", got.ProposedContent, got.MergedContent)
|
||
}
|
||
if got.Status != tidyCandidatePending {
|
||
t.Fatalf("status = %q, want %q(人工确认队列)", got.Status, tidyCandidatePending)
|
||
}
|
||
}
|
||
|
||
// TestTidyParallelFactsProduceNoCandidate 是主人红线「不要什么内容都合并」与
|
||
// design/23 §12.1 P8 的口径:相似但并列的事实(红牛 vs 冰美式)**既不合并,也不产
|
||
// link 候选**(2026-10-07 主人口径「那么多 link 是干什么的?该删就删」)。
|
||
//
|
||
// 为什么连 link 候选都不产:design/23 §14 把「link 关系如何存储与呈现」列为**待细化**
|
||
// ⇒ 没有落点、没有数据模型;执行面只认 merge/archive/reclassify(tidy_apply.go 的
|
||
// applyable())⇒ 一条 link 候选的应用结果永远是 skipped/action_not_applyable。
|
||
// 老的用例名是 TestTidyParallelFactsOnlyLink(曾经断言「恰产一条 link」),那正是
|
||
// 活库积压 501 条无执行语义候选的来源。
|
||
func TestTidyParallelFactsProduceNoCandidate(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
redBull := seedTidyItem(t, store, habitItem(fixtureEnergyRedBull, base, 0.95))
|
||
iced := seedTidyItem(t, store, habitItem(fixtureEnergyIced, base.Add(time.Minute), 0.95))
|
||
|
||
tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
|
||
fixtureEnergyRedBull: {1, 0},
|
||
fixtureEnergyIced: simVector(0.99),
|
||
}})
|
||
stats, err := tr.RunOnce(ctx)
|
||
if err != nil {
|
||
t.Fatalf("RunOnce: %v", err)
|
||
}
|
||
|
||
cands := allTidyCandidates(t, store, 50)
|
||
if len(cands) != 0 {
|
||
t.Fatalf("并列事实不该产任何候选(含 link):%+v(主人红线:不要什么内容都合并)", cands)
|
||
}
|
||
if stats.Kept != 1 {
|
||
t.Fatalf("stats.Kept = %d, want 1(抛掉的对必须单独计数,否则口径上是消失)", stats.Kept)
|
||
}
|
||
// 「什么都不做」必须是**真什么都没做**:两行都还在册。
|
||
for _, id := range []string{redBull.ID, iced.ID} {
|
||
if got := loadTidyItemRow(t, store, id); got.status != tidyStatusActive {
|
||
t.Fatalf("并列事实被改动了:%+v", got)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestTidyCrossScopeNeverComparedOrMerged 是 design/23 §12.2 N1 与 §6 硬约束:
|
||
// 跨作用域([10] user vs [19] persona,全库相似度最高的一对 0.9319)永不合 ——
|
||
// 运行期连比较都不发生(分区隔离),判定层再兜一次否决。
|
||
func TestTidyCrossScopeNeverComparedOrMerged(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
content := "主人喜欢被叫小笨蛋"
|
||
userItem := seedTidyItem(t, store, MemoryItem{
|
||
PersonaID: "persona.a", Scope: ScopeUser, Kind: KindRelationship,
|
||
Content: content, Confidence: 0.95, CreatedAt: base,
|
||
})
|
||
personaItem := habitItem(content, base.Add(time.Minute), 0.95)
|
||
seedTidyItem(t, store, personaItem)
|
||
|
||
emb := stubEmbedder{vecs: map[string]embedding.Vector{content: {1, 0}}}
|
||
|
||
// 判定层:跨作用域对被硬门否决(纵深防御,design §5.2-7)。
|
||
verdict := judgePair(userItem, personaItem, 0.9319)
|
||
if verdict.Veto != VetoScope {
|
||
t.Fatalf("judgePair 跨作用域 veto = %q, want %q", verdict.Veto, VetoScope)
|
||
}
|
||
if verdict.Action != "" {
|
||
t.Fatalf("被判否决的对不得带动作,got %q", verdict.Action)
|
||
}
|
||
|
||
// 运行期:分区之间永不比较 ⇒ 零候选(design §6「跨分区永不产生候选」)。
|
||
tr := newTestTidier(t, store, tidyTestCfg(0.80), emb)
|
||
stats, err := tr.RunOnce(ctx)
|
||
if err != nil {
|
||
t.Fatalf("RunOnce: %v", err)
|
||
}
|
||
if stats.Pairs != 0 || stats.Candidates != 0 {
|
||
t.Fatalf("跨作用域产出了候选:pairs=%d candidates=%d", stats.Pairs, stats.Candidates)
|
||
}
|
||
cands, err := store.ListTidyCandidates(ctx, "", 50)
|
||
if err != nil {
|
||
t.Fatalf("ListTidyCandidates: %v", err)
|
||
}
|
||
if len(cands) != 0 {
|
||
t.Fatalf("跨作用域候选 = %+v, want 空", cands)
|
||
}
|
||
}
|
||
|
||
// TestTidyTemporalPollutionVetoed 是 design/23 §12.2 N2 与 §5.2-1:一次性事件
|
||
// 与有期限状态不得被当长期事实合并;含绝对时刻的条目单独走 archive。
|
||
func TestTidyTemporalPollutionVetoed(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
plain := seedTidyItem(t, store, habitItem(fixtureHabitPlain, base, 0.9))
|
||
vacation := seedTidyItem(t, store, habitItem(fixtureHabitVacation, base.Add(time.Minute), 0.9))
|
||
oneOff := seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base.Add(2*time.Minute), 0.9))
|
||
|
||
// 判定层:[8] 那类一次性事件与长期习惯硬不合。
|
||
if v := judgePair(plain, vacation, 0.95); v.Veto != VetoTemporal {
|
||
t.Fatalf("时效状态 veto = %q, want %q", v.Veto, VetoTemporal)
|
||
}
|
||
if v := judgePair(oneOff, plain, 0.95); v.Veto != VetoTemporal {
|
||
t.Fatalf("一次性事件 veto = %q, want %q", v.Veto, VetoTemporal)
|
||
}
|
||
|
||
tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
|
||
fixtureHabitPlain: {1, 0},
|
||
fixtureHabitVacation: simVector(0.97),
|
||
fixtureOneOffEvent: simVector(0.96),
|
||
}})
|
||
stats, err := tr.RunOnce(ctx)
|
||
if err != nil {
|
||
t.Fatalf("RunOnce: %v", err)
|
||
}
|
||
if stats.VetoReasons[string(VetoTemporal)] < 1 {
|
||
t.Fatalf("否决原因分布里没有 temporal:%+v", stats.VetoReasons)
|
||
}
|
||
|
||
cands := allTidyCandidates(t, store, 50)
|
||
if merges := candidatesByAction(cands, TidyMerge); len(merges) != 0 {
|
||
t.Fatalf("时效污染被合并:%+v", merges)
|
||
}
|
||
archives := candidatesByAction(cands, TidyArchive)
|
||
if len(archives) != 1 || archives[0].SourceIDs[0] != oneOff.ID {
|
||
t.Fatalf("archive 候选 = %+v, want 恰为一次性事件 %s", archives, oneOff.ID)
|
||
}
|
||
for _, c := range cands {
|
||
if containsID(c.SourceIDs, oneOff.ID) && c.Action != TidyArchive {
|
||
t.Fatalf("一次性事件参与了 %s:%+v", c.Action, c)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestTidyEmptyAndSingleItemAreNoops 是 design/23 §12 的边界口径:空库与单条
|
||
// 记忆都不产候选、不报错,但仍留一条运行痕迹(不静默)。
|
||
func TestTidyEmptyAndSingleItemAreNoops(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
tr := newTestTidier(t, store, tidyTestCfg(0.80), nil)
|
||
|
||
stats, err := tr.RunOnce(ctx)
|
||
if err != nil {
|
||
t.Fatalf("空库 RunOnce: %v", err)
|
||
}
|
||
if stats.Scanned != 0 || stats.Candidates != 0 || stats.Pairs != 0 {
|
||
t.Fatalf("空库 stats = %+v, want 全 0", stats)
|
||
}
|
||
|
||
seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9))
|
||
stats, err = tr.RunOnce(ctx)
|
||
if err != nil {
|
||
t.Fatalf("单条 RunOnce: %v", err)
|
||
}
|
||
if stats.Scanned != 1 || stats.Candidates != 0 || stats.Pairs != 0 {
|
||
t.Fatalf("单条 stats = %+v, want scanned=1/candidates=0/pairs=0", stats)
|
||
}
|
||
|
||
runs, err := store.ListTidyRuns(ctx, 10)
|
||
if err != nil {
|
||
t.Fatalf("ListTidyRuns: %v", err)
|
||
}
|
||
if len(runs) != 2 {
|
||
t.Fatalf("运行留痕 = %d 条, want 2", len(runs))
|
||
}
|
||
}
|
||
|
||
// TestTidyRerunIsIdempotent 是 design/23 §12.2 N6 与 §9:同一批数据连跑两轮
|
||
// 不重复产候选(幂等键 = 规则版本 + 数据指纹,不靠「记住上次」)。
|
||
//
|
||
// 本用例刻意**关掉轮末自动档**:自动档会把一次性事件当场归档,于是第二轮扫描范围
|
||
// 本身就变小了(归档行不再参与比对),duplicates 会被自动档的结果影响 ——
|
||
// 那是自动档的语义(另有 tidy_auto_test.go 专门验),不是「候选产出」这条幂等性的
|
||
// 一部分。这里要证明的是:规则不变时同一批数据绝不重复产候选。
|
||
func TestTidyRerunIsIdempotent(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
|
||
seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
|
||
seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base.Add(2*time.Hour), 0.9))
|
||
|
||
cfg := tidyTestCfg(0.80)
|
||
cfg.DisableAutoApply = true
|
||
tr := newTestTidier(t, store, cfg, stubEmbedder{vecs: map[string]embedding.Vector{
|
||
fixtureHabitShort: {1, 0},
|
||
fixtureHabitLong: simVector(0.95),
|
||
fixtureOneOffEvent: simVector(0.9),
|
||
}})
|
||
first, err := tr.RunOnce(ctx)
|
||
if err != nil {
|
||
t.Fatalf("第一轮 RunOnce: %v", err)
|
||
}
|
||
if first.Candidates == 0 {
|
||
t.Fatalf("第一轮没有产出候选,用例无效:%+v", first)
|
||
}
|
||
before, err := store.ListTidyCandidates(ctx, "", 100)
|
||
if err != nil {
|
||
t.Fatalf("ListTidyCandidates: %v", err)
|
||
}
|
||
|
||
second, err := tr.RunOnce(ctx)
|
||
if err != nil {
|
||
t.Fatalf("第二轮 RunOnce: %v", err)
|
||
}
|
||
if second.Candidates != 0 {
|
||
t.Fatalf("第二轮新产候选 = %d, want 0(幂等)", second.Candidates)
|
||
}
|
||
if second.Duplicates != first.Candidates {
|
||
t.Fatalf("第二轮 duplicates = %d, want %d", second.Duplicates, first.Candidates)
|
||
}
|
||
after, err := store.ListTidyCandidates(ctx, "", 100)
|
||
if err != nil {
|
||
t.Fatalf("ListTidyCandidates: %v", err)
|
||
}
|
||
if len(after) != len(before) {
|
||
t.Fatalf("候选行数 %d → %d, want 不变", len(before), len(after))
|
||
}
|
||
}
|
||
|
||
func containsID(ids []string, id string) bool {
|
||
for _, v := range ids {
|
||
if v == id {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|