248 lines
8.6 KiB
Go
248 lines
8.6 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"math"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||
)
|
||
|
||
// 本文件收拢记忆整理的**否决与边界/配置**用例:来源硬门、低置信、kind 误分类、
|
||
// 每分区配额、离线回退、设计稿默认值与 schema。主流程(包含→合并 / 并列→链接 /
|
||
// 跨作用域 / 时效污染 / 空库 / 幂等)在 tidy_test.go。
|
||
// TestTidyGroupSourceExcluded 是 design/23 §12.2 N7 与 §5.2-9/§6:群聊/陌生人
|
||
// 来源的条目既不合并也不归档,只读不写。
|
||
func TestTidyGroupSourceExcluded(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
groupItem := seedTidyItem(t, store, MemoryItem{
|
||
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
|
||
Content: fixtureHabitShort, Confidence: 0.9, CreatedAt: base,
|
||
SourceConversation: FormatSourceConversation("sess-telegram:group1", "msg-9"),
|
||
})
|
||
ownerItem := seedTidyItem(t, store, habitItem(fixtureHabitShort, base.Add(time.Minute), 0.9))
|
||
|
||
tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
|
||
fixtureHabitShort: {1, 0},
|
||
}})
|
||
stats, err := tr.RunOnce(ctx)
|
||
if err != nil {
|
||
t.Fatalf("RunOnce: %v", err)
|
||
}
|
||
if stats.VetoReasons[string(VetoSource)] < 1 {
|
||
t.Fatalf("否决原因分布里没有 source:%+v", stats.VetoReasons)
|
||
}
|
||
cands, err := store.ListTidyCandidates(ctx, "", 50)
|
||
if err != nil {
|
||
t.Fatalf("ListTidyCandidates: %v", err)
|
||
}
|
||
for _, c := range cands {
|
||
if containsID(c.SourceIDs, groupItem.ID) {
|
||
t.Fatalf("群聊来源条目进了候选:%+v", c)
|
||
}
|
||
}
|
||
if ownerItem.ID == "" {
|
||
t.Fatal("owner 条目应当落库成功")
|
||
}
|
||
}
|
||
|
||
// TestTidyLowConfidenceVetoed 是 design/23 §12.2 N4 与 §5.2-6:confidence 不是
|
||
// 合并门,但必须是否决门。
|
||
func TestTidyLowConfidenceVetoed(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
low := seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.6))
|
||
high := seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
|
||
|
||
if v := judgePair(low, high, 0.99); v.Veto != VetoLowConfidence {
|
||
t.Fatalf("低置信 veto = %q, want %q", v.Veto, VetoLowConfidence)
|
||
}
|
||
tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
|
||
fixtureHabitShort: {1, 0},
|
||
fixtureHabitLong: simVector(0.97),
|
||
}})
|
||
if _, err := tr.RunOnce(ctx); err != nil {
|
||
t.Fatalf("RunOnce: %v", err)
|
||
}
|
||
cands, err := store.ListTidyCandidates(ctx, "", 50)
|
||
if err != nil {
|
||
t.Fatalf("ListTidyCandidates: %v", err)
|
||
}
|
||
if len(cands) != 0 {
|
||
t.Fatalf("低置信对产出了候选:%+v", cands)
|
||
}
|
||
}
|
||
|
||
// TestTidyKindMismatchAlsoSuggestsReclassify 是 design/23 §12.1 P7 的口径:
|
||
// 包含关系成立但 kind 不同 ⇒ 既产 merge 候选,也产 reclassify 候选(都交人工)。
|
||
func TestTidyKindMismatchAlsoSuggestsReclassify(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
short := MemoryItem{
|
||
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindWorldEvent,
|
||
Content: fixtureHabitShort, Confidence: 0.9, CreatedAt: base,
|
||
}
|
||
long := MemoryItem{
|
||
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
|
||
Content: fixtureHabitLong, Confidence: 0.9, CreatedAt: base.Add(time.Hour),
|
||
}
|
||
seedTidyItem(t, store, short)
|
||
seedTidyItem(t, store, long)
|
||
|
||
tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
|
||
fixtureHabitShort: {1, 0},
|
||
fixtureHabitLong: simVector(0.95),
|
||
}})
|
||
if _, err := tr.RunOnce(ctx); err != nil {
|
||
t.Fatalf("RunOnce: %v", err)
|
||
}
|
||
cands, err := store.ListTidyCandidates(ctx, "", 50)
|
||
if err != nil {
|
||
t.Fatalf("ListTidyCandidates: %v", err)
|
||
}
|
||
if len(candidatesByAction(cands, TidyMerge)) != 1 || len(candidatesByAction(cands, TidyReclassify)) != 1 {
|
||
t.Fatalf("期望 1 条 merge + 1 条 reclassify,实际 %+v", cands)
|
||
}
|
||
}
|
||
|
||
// TestTidyCandidateLimitPerPartition 锁定 design/23 §9 的每分区候选上限:
|
||
// 超出的只登记不入队,且绝不静默。
|
||
func TestTidyCandidateLimitPerPartition(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
shortA, longA := fixtureWorkShort, fixtureWorkLong
|
||
shortB, longB := "主人喜欢被叫小笨蛋", "主人喜欢被叫小笨蛋,还喜欢被摸摸头"
|
||
seedTidyItem(t, store, habitItem(shortA, base, 0.9))
|
||
seedTidyItem(t, store, habitItem(longA, base.Add(time.Minute), 0.9))
|
||
seedTidyItem(t, store, habitItem(shortB, base.Add(2*time.Minute), 0.9))
|
||
seedTidyItem(t, store, habitItem(longB, base.Add(3*time.Minute), 0.9))
|
||
|
||
cfg := tidyTestCfg(0.80)
|
||
cfg.CandidateLimit = 1
|
||
// 两族向量彼此近似正交(跨族 cosine ≈ 0.31 < 0.80),因此恰好召回 2 对。
|
||
tr := newTestTidier(t, store, cfg, stubEmbedder{vecs: map[string]embedding.Vector{
|
||
shortA: {1, 0},
|
||
longA: simVector(0.95),
|
||
shortB: {0, 1},
|
||
longB: {float32(math.Sqrt(1 - 0.95*0.95)), 0.95},
|
||
}})
|
||
|
||
stats, err := tr.RunOnce(ctx)
|
||
if err != nil {
|
||
t.Fatalf("RunOnce: %v", err)
|
||
}
|
||
if stats.Candidates != 1 {
|
||
t.Fatalf("候选 = %d, want 1(每分区上限)", stats.Candidates)
|
||
}
|
||
if stats.Skipped < 1 {
|
||
t.Fatalf("超限未登记:skipped=%d", stats.Skipped)
|
||
}
|
||
}
|
||
|
||
// TestTidyOfflineLexicalRecall 锁定「无 embedder 时的零依赖回退」:整理在没有
|
||
// embedding 配置时仍能召回并判定(阈值仍是可配的召回门)。
|
||
func TestTidyOfflineLexicalRecall(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))
|
||
|
||
cfg := tidyTestCfg(0.5)
|
||
tr := newTestTidier(t, store, cfg, nil)
|
||
stats, err := tr.RunOnce(ctx)
|
||
if err != nil {
|
||
t.Fatalf("RunOnce: %v", err)
|
||
}
|
||
if stats.Pairs != 1 {
|
||
t.Fatalf("词项余弦召回 pairs = %d, want 1", stats.Pairs)
|
||
}
|
||
cands, err := store.ListTidyCandidates(ctx, "", 50)
|
||
if err != nil {
|
||
t.Fatalf("ListTidyCandidates: %v", err)
|
||
}
|
||
if len(candidatesByAction(cands, TidyMerge)) != 1 {
|
||
t.Fatalf("离线回退未产出 merge 候选:%+v", cands)
|
||
}
|
||
}
|
||
|
||
// TestTidyConfigDefaults 锁定「设计稿默认值只有一处定义」:召回门 0.80、
|
||
// 每天 1 次、安静时段 23:00-07:00、每分区扫描 5000 / 候选 20(design §9/§13)。
|
||
func TestTidyConfigDefaults(t *testing.T) {
|
||
t.Parallel()
|
||
cfg := normalizeTidyConfig(TidyConfig{Enabled: true})
|
||
if cfg.IntervalSec != 86400 || cfg.Interval != 24*time.Hour {
|
||
t.Fatalf("周期默认 = %ds / %s, want 86400s / 24h", cfg.IntervalSec, cfg.Interval)
|
||
}
|
||
if cfg.RecallThreshold != 0.80 {
|
||
t.Fatalf("召回门默认 = %v, want 0.80", cfg.RecallThreshold)
|
||
}
|
||
if cfg.ScanLimit != 5000 || cfg.CandidateLimit != 20 {
|
||
t.Fatalf("配额默认 = %d/%d, want 5000/20", cfg.ScanLimit, cfg.CandidateLimit)
|
||
}
|
||
if cfg.QuietStartHour != 23 || cfg.QuietEndHour != 7 {
|
||
t.Fatalf("安静时段默认 = %d-%d, want 23-7", cfg.QuietStartHour, cfg.QuietEndHour)
|
||
}
|
||
if TidySessionID != "sess-memorytidy" {
|
||
t.Fatalf("隔离会话 = %q, want sess-memorytidy", TidySessionID)
|
||
}
|
||
}
|
||
|
||
// TestTidySchemaCreatedByNewStore 锁定「整理在任意 *sql.DB 上都可用」:
|
||
// NewStore 幂等创建候选表/运行表并前滚 memory_items 的整理列。
|
||
func TestTidySchemaCreatedByNewStore(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
if _, err := NewStore(ctx, store.db, testLogger()); err != nil {
|
||
t.Fatalf("第二次 NewStore(幂等): %v", err)
|
||
}
|
||
for _, table := range []string{"memory_tidy_candidates", "memory_tidy_runs"} {
|
||
var n int
|
||
if err := store.db.QueryRowContext(ctx,
|
||
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name = ?", table).Scan(&n); err != nil {
|
||
t.Fatalf("查询表 %s: %v", table, err)
|
||
}
|
||
if n != 1 {
|
||
t.Fatalf("表 %s 不存在", table)
|
||
}
|
||
}
|
||
rows, err := store.db.QueryContext(ctx, "PRAGMA table_info(memory_items)")
|
||
if err != nil {
|
||
t.Fatalf("pragma table_info: %v", err)
|
||
}
|
||
defer rows.Close()
|
||
cols := map[string]bool{}
|
||
for rows.Next() {
|
||
var (
|
||
cid int
|
||
name, typ string
|
||
notNull int
|
||
dflt any
|
||
pk int
|
||
)
|
||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil {
|
||
t.Fatalf("scan pragma: %v", err)
|
||
}
|
||
cols[name] = true
|
||
}
|
||
for _, want := range []string{"status", "merged_into", "merged_from", "merge_count", "occurred_at", "valid_to", "expires_at"} {
|
||
if !cols[want] {
|
||
t.Fatalf("memory_items 缺列 %s(现有列:%v)", want, cols)
|
||
}
|
||
}
|
||
}
|
||
|
||
// containsID 报告 ids 里是否含 id。
|