Files
HomeAgent/internal/memory/scene_dedupe_test.go
JianFeeeee 758ec11832 fix(memory): 图整备覆盖 scenes + 证据桶按桶清(R3/R5)
R3:图整理心跳只查 entities/relations,scenes 完全没有整备路径。
Recall(nil,nil,1,"") 的全量路径只 SELECT 这两张表
(graph.go:609/631),于是同一场面的双胞胎键从建库起无人发现:
auto:chan:qq+part:morning 累积到 strength=271 / 6 features / 0 refs,
孪生的 auto:chan:qq_part:morning 持有 210 refs 却 0 features
(聚类只读 scene_features,所以它永远不被看见)。

新增 GraphDB.DedupeScenes:归一化后同名的场景合成一个——强度相加、
特征取并集(权重取大)、引用全部重定向,存活者保留 id 最小行,
跨 origin 也合。接到 mergeLoop 尾部。

为什么不塞进 detectEntityMerge 的双重循环:
- 实体是全库两两 bigram + LLM 裁决(1 万实体实测 5000 万次配对、
  ~224GB 瞬时分配每轮,是独立问题);
- 场景的判重口径是**归一化后是否同名**——同名即同一场面,键相同
  本身就是证据,不需要 LLM 裁决。而「像不像」是 EnterScene 聚类的
  职责,不是这里的事。
只做同键合并、不做相似度合并:把 chan:qq 与 chan:webui 合并是危险
的,去重不是「把像的一律合并」。

生产库副本实测(sqlite3 备份式复制到 /tmp,未碰生产):
65 个场景 → 合并 8 组 → 57 个;
auto:chan:qq_part:morning 的 refs/rel/ent 一条没丢,strength 1 → 272。

R5:createSceneLocked 新场景成立时执行的是
`DELETE FROM situation_evidence`(全表清),而证据表是多通道共用的
计数桶。后果不是「多清一点」:qq 的场景一长出来,就把 mc/webui/cli
尚未攒够 minSceneEvidence=2 的证据抹掉,它们的计数被反复清零,
于是**永远**攒不到 2 次。判据实测:6 个通道各来 3 次,只长出 2 个场景。
改为 `DELETE ... WHERE label = ?`,只清本指纹那个桶。

判据:scene_dedupe_test.go 8 例(含「不同场面不得被合并」与幂等)、
scene_evidence_test.go 3 例。均先红后绿。修 R5 时差点栽:桶键是
sig.Label(2) 本身、不带 auto: 前缀(base 才是带前缀的场景键),
第一版删错对象会「一条没删却看起来通过」,用探针实测真实桶键后改正。

记忆 8 包 + agent/core 全绿,8 包齐全、无 FAIL/panic/race。
2026-09-26 20:16:11 +08:00

286 lines
8.9 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
// 场景去重(R3)的判据。
//
// 缺口事实:图整理心跳(mergeLoop → detectEntityMerge)唯一的遍历入口是
// Recall(nil,nil,1,""),而该全量路径只查 entities(graph.go:609) 与
// relations(graph.go:631) —— scenes 不在其中。于是同一个场面的双胞胎键
// (auto:chan:qq+part:morning ↔ auto:chan:qq_part:morning)从建库起
// 无人发现:强度一路涨到 270、6 个 features、0 条记忆,而孪生的那个
// 持有 201 条记忆却有 0 features。两套特征体系各活各的。
//
// 判据参照物在生产代码之外:期望值是「归一化后同名的场景必须合成一个」
// 与「合并后记忆/特征/强度都不丢」这两条不变量,不引用被测实现。
import (
"os"
"strings"
"testing"
)
// insertSceneRow 直写一行 scenes(绕开建键路径),用来复现历史双胞胎。
func insertSceneRow(t *testing.T, g *GraphDB, key, origin string, strength int) int64 {
t.Helper()
res, err := g.db.Exec(
`INSERT INTO scenes (key, strength, origin) VALUES (?, ?, ?)`,
key, strength, origin)
if err != nil {
t.Fatal(err)
}
id, _ := res.LastInsertId()
return id
}
func addFeature(t *testing.T, g *GraphDB, sceneID int64, feature string, weight float64) {
t.Helper()
if _, err := g.db.Exec(
`INSERT INTO scene_features (scene_id, feature, weight) VALUES (?, ?, ?)`,
sceneID, feature, weight); err != nil {
t.Fatal(err)
}
}
func addRef(t *testing.T, g *GraphDB, sceneID int64, kind string, refID int64, weight float64) {
t.Helper()
if _, err := g.db.Exec(
`INSERT INTO scene_refs (scene_id, kind, ref_id, weight) VALUES (?, ?, ?, ?)`,
sceneID, kind, refID, weight); err != nil {
t.Fatal(err)
}
}
func sceneRowCount(t *testing.T, g *GraphDB) int {
t.Helper()
var n int
if err := g.db.QueryRow(`SELECT COUNT(*) FROM scenes`).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
func sceneRefCount(t *testing.T, g *GraphDB) int {
t.Helper()
var n int
if err := g.db.QueryRow(`SELECT COUNT(*) FROM scene_refs`).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
// R3 核心:归一化后同名的双胞胎必须合成一个。
func TestDedupeScenes_MergesNormalizedTwins(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
// 复现现网形态:一个带 +(有 features、无 refs),一个带 _(有 refs、无 features)
plus := insertSceneRow(t, g, "auto:chan:qq+part:morning", "emergent", 270)
under := insertSceneRow(t, g, "auto:chan:qq_part:morning", "emergent", 1)
addFeature(t, g, plus, "chan:qq", 1.0)
addFeature(t, g, plus, "part:morning", 0.2)
addRef(t, g, under, "relation", 42, 1.0)
addRef(t, g, under, "entity", 7, 1.0)
merged, err := g.DedupeScenes()
if err != nil {
t.Fatalf("DedupeScenes 出错: %v", err)
}
if merged != 1 {
t.Errorf("应合并 1 组,实际 %d", merged)
}
if n := sceneRowCount(t, g); n != 1 {
t.Fatalf("合并后 scenes 应剩 1 行,实际 %d:\n%v", n, sceneKeys(t, g))
}
}
// 合并绝不能丢记忆:refs 要全部转到存活的那一行。
func TestDedupeScenes_PreservesRefs(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
plus := insertSceneRow(t, g, "auto:chan:mc:event+topic:mc", "emergent", 50)
under := insertSceneRow(t, g, "auto:chan:mc:event_topic:mc", "emergent", 1)
addFeature(t, g, plus, "chan:mc:event", 1.0)
addRef(t, g, under, "relation", 1, 1.0)
addRef(t, g, under, "relation", 2, 1.0)
addRef(t, g, under, "entity", 3, 1.0)
if _, err := g.DedupeScenes(); err != nil {
t.Fatal(err)
}
// 一条都不能少
if got := sceneRefCount(t, g); got != 3 {
t.Fatalf("合并后 scene_refs 应有 3 条,实际 %d —— 合并丢了记忆", got)
}
// 且全部挂在存活的那一行上
var sceneID int64
if err := g.db.QueryRow(`SELECT id FROM scenes LIMIT 1`).Scan(&sceneID); err != nil {
t.Fatal(err)
}
var onSurvivor int
if err := g.db.QueryRow(
`SELECT COUNT(*) FROM scene_refs WHERE scene_id = ?`, sceneID).Scan(&onSurvivor); err != nil {
t.Fatal(err)
}
if onSurvivor != 3 {
t.Errorf("存活场景上只挂了 %d/3 条 refs", onSurvivor)
}
}
// 特征取并集:哪一侧有就保留,权重取大。
func TestDedupeScenes_UnionsFeatures(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
plus := insertSceneRow(t, g, "auto:chan:qq+topic:排班", "emergent", 3)
under := insertSceneRow(t, g, "auto:chan:qq_topic:排班", "emergent", 2)
addFeature(t, g, plus, "chan:qq", 1.0)
addFeature(t, g, plus, "topic:排班", 0.4)
addFeature(t, g, under, "chan:qq", 1.0)
addFeature(t, g, under, "peer:boss", 1.0) // 只在孪生那侧有
if _, err := g.DedupeScenes(); err != nil {
t.Fatal(err)
}
var n int
if err := g.db.QueryRow(`SELECT COUNT(*) FROM scene_features`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 3 {
t.Errorf("特征并集应为 3(chan:qq / topic:排班 / peer:boss),实际 %d", n)
}
}
// strength 要相加:两个场景各被遇到过 N 次,合起来就该是 2N。
func TestDedupeScenes_SumsStrength(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
insertSceneRow(t, g, "auto:chan:qq+part:morning", "emergent", 270)
insertSceneRow(t, g, "auto:chan:qq_part:morning", "emergent", 1)
if _, err := g.DedupeScenes(); err != nil {
t.Fatal(err)
}
var strength int
if err := g.db.QueryRow(`SELECT strength FROM scenes LIMIT 1`).Scan(&strength); err != nil {
t.Fatal(err)
}
if strength != 271 {
t.Errorf("strength 应为 270+1=271,实际 %d", strength)
}
}
// 幂等:跑两次,第二次必须是 0 合并、0 行变化。
func TestDedupeScenes_IsIdempotent(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
insertSceneRow(t, g, "auto:chan:qq+part:morning", "emergent", 270)
insertSceneRow(t, g, "auto:chan:qq_part:morning", "emergent", 1)
addRef(t, g, 2, "relation", 1, 1.0)
if n, err := g.DedupeScenes(); err != nil || n != 1 {
t.Fatalf("首次应合并 1 组,实际 n=%d err=%v", n, err)
}
rows, refs, strength := sceneRowCount(t, g), sceneRefCount(t, g), 271
n2, err := g.DedupeScenes()
if err != nil {
t.Fatal(err)
}
if n2 != 0 {
t.Errorf("第二次不应再合并,实际 %d", n2)
}
if got := sceneRowCount(t, g); got != rows {
t.Errorf("第二次改变了行数: %d → %d", rows, got)
}
if got := sceneRefCount(t, g); got != refs {
t.Errorf("第二次改变了 refs: %d → %d", refs, got)
}
var s int
if err := g.db.QueryRow(`SELECT strength FROM scenes LIMIT 1`).Scan(&s); err != nil {
t.Fatal(err)
}
if s != strength {
t.Errorf("第二次把 strength 改成了 %d(应保持 %d)", s, strength)
}
}
// 不同场面不能被合到一起:只有归一化后**完全同名**才算重复。
// 这是本判据的另一半——去重不能变成"把相似的一律合并"。
func TestDedupeScenes_KeepsDistinctScenes(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
insertSceneRow(t, g, "auto:chan:qq", "emergent", 5)
insertSceneRow(t, g, "auto:chan:webui", "emergent", 6)
insertSceneRow(t, g, "auto:chan:qq_part:morning", "emergent", 270)
addFeature(t, g, 1, "chan:qq", 1.0)
addFeature(t, g, 2, "chan:webui", 1.0)
n, err := g.DedupeScenes()
if err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("三个不同场面不该被合并,实际合并了 %d 组", n)
}
if got := sceneRowCount(t, g); got != 3 {
t.Errorf("应有 3 个场景,实际 %d: %v", got, sceneKeys(t, g))
}
}
// 声明场景与涌现场景归一化后同名时也要合——现网 chan:context_archived
// 就是这么来的(origin='emergent' 却长得像声明键)。
func TestDedupeScenes_MergesAcrossOrigins(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
insertSceneRow(t, g, "chan:qq", "declared", 281)
insertSceneRow(t, g, "chan:QQ", "declared", 3) // 仅大小写不同
n, err := g.DedupeScenes()
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("大小写不同的同名场景应合并,实际 %d 组", n)
}
if got := sceneRowCount(t, g); got != 1 {
t.Errorf("应剩 1 行,实际 %d: %v", got, sceneKeys(t, g))
}
}
// 归一化口径必须与写/读侧一致:这里独立复算一遍期望名,
// 避免判据与被测实现共用同一个 NormalizeSceneKey 而一起错。
func TestDedupeScenes_UsesSameNormalizationAsWriteSide(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
// 写侧(effectiveScenes)会把 "auto:chan:qq+part:morning" 归一成什么?
wantKey := NormalizeSceneKey("auto:chan:qq+part:morning")
if strings.Contains(wantKey, "+") {
t.Fatalf("前提不成立:NormalizeSceneKey 未处理 '+',got %q", wantKey)
}
insertSceneRow(t, g, "auto:chan:qq+part:morning", "emergent", 1)
insertSceneRow(t, g, wantKey, "emergent", 1)
if _, err := g.DedupeScenes(); err != nil {
t.Fatal(err)
}
if got := sceneRowCount(t, g); got != 1 {
t.Errorf("建键侧的未归一化键应与写侧归一化后的键合并,实际剩 %d 行", got)
}
}