Files
Cyrene_v2/internal/runtime/memory/tidy_veto_test.go
T
AskaEth 6e28b38b08
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 护栏抬高+时间预算闸+试算加性异步 与 D3 入库前审查(耦合两片一起落)
【MT-A:护栏 + 诚实截断 + 试算异步】
- maxTidyCompareItems 400(写死)→2000 并开放为 MEMORY_TIDY_COMPARE_LIMIT;
  MEMORY_TIDY_CANDIDATE_LIMIT 默认 20→200(语义不变,仍每分区)
- 依据是首跑实测:459 条的分区被 400 截断 ⇒ compared(705) < scanned(784),
  候选数(77)由上限而不是规则决定;三处上限的 WARN 都补齐了总数/上限/分区名
- 新增「上限 + 时间预算」双闸(实测 2000 条/分区 ≈234s > 3 分钟墙钟):
  整分区跳过 / 两两比对中途收手,一律如实记 (budget) + WARN
- ?async=1 ⇒ 202 {runId,status};GET …/{runId} 轮询(writeAuth 同档);
  done 的 result 与同步 data 逐字节同形;并发=复用既有 runId、TTL 30min 自完成起算、
  重启即如实 404 tidy_run_not_found;不带 async 时同步路径逐字不变

【MT-B:D3 入库前审查(design/23 §15.1)】
- 新记忆经 Store.Add(仓库里唯一 INSERT INTO memory_items)落库后,由独立记忆管理
  会话(sess-memorytidy)在有界后台队列异步判定 skip/merge/modify/create
- 软合并回写(status + merged_into/merged_from/merge_count,可 revert、绝不改 content)
- 隔离来源(群聊/陌生人)**概不审查**、召回/超时失败**零改写**(fail-safe 结构性成立)
- 写入侧仅 +62.7µs/条(非阻塞入队);默认 ENABLED=true(主人裁定:入库前先审查)

【耦合说明(重要)】两片同包且互相依赖:MT-B 的 preinsert_recall.go 用 MT-A 的
collectTidyPairs 新签名 + tidyBudget ⇒ **各自单独都 build 不过**,故一并落地。
MT-A 为让整包编译,最小幅度适配了 MT-B 的 preinsert_recall.go / preinsert_test.go
(零值预算=行为不变;符号重名改名)。

【我的验收】隔离 worktree(37 文件)build/vet/linux/darwin 全过,切片包全绿;
我亲手打红两个变异:① 去掉 (compare) 截断标记 ⇒ TestTidyCompareLimit_HitIsLoud 红;
② 中和隔离来源守卫 ⇒ TestReviewRestrictedSourceIsNeverTouched 红。
唯一红是 internal/runtime/tool 的两条超时用例(该包无人改)⇒ 主仓库 -count=8 全绿,
判定为负载敏感 flake(当时两个整树验收 + 子代理并发),已记入开发习惯与约定 §7。

.env.example 补 MEMORY_REVIEW_* 四键(MT-B 无该文件权限,由我按其代码语义补写)。
2026-10-06 08:04:12 +08:00

250 lines
8.8 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"
"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 / 候选 200 / 比对 2000
// (design §9/§13;后两个是切片 MT-A 抬高的护栏,见 tidy_config.go 的注释)。
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 != 200 || cfg.CompareLimit != 2000 {
t.Fatalf("配额默认 = %d/%d/%d, want 5000/200/2000(scan/candidate/compare)",
cfg.ScanLimit, cfg.CandidateLimit, cfg.CompareLimit)
}
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, &notNull, &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。