Files
HomeAgent/internal/memory/scene_evidence_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

130 lines
3.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
// R5 的判据:场景成立时清证据,只清**本指纹那个桶**,不是全表。
//
// 现状(scene_emerge.go createSceneLocked 末尾):
//
// DELETE FROM situation_evidence ← 全表清
//
// 后果:多场景并发轮次下,A 场景的建立会连带清掉 B 尚未攒够
// minSceneEvidence=2 的证据 ⇒ 门槛判定被「别的场景刚好长出来」随机打断。
// 这不是理论:QQ / mc / webui 三个通道在同一进程里各自计数。
//
// 判据参照物在生产代码之外:期望值是「建一个场景后,其它桶的证据必须原样还在」。
import (
"os"
"testing"
)
// evidenceCount 读某桶的累计次数(直查表,不走被测函数)。
func evidenceCount(t *testing.T, g *GraphDB, label string) int {
t.Helper()
var n int
err := g.db.QueryRow(
`SELECT COALESCE((SELECT count FROM situation_evidence WHERE label = ?), 0)`, label,
).Scan(&n)
if err != nil {
t.Fatal(err)
}
return n
}
// A 场景成立时,B 桶的证据不得被动。
func TestSceneCreationKeepsOtherEvidence(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
qq := mkSig("qq", "", "")
webui := mkSig("webui", "", "")
// 两个通道各登记一次证据(都还没到门槛)
if _, _, err := g.EnterScene(qq); err != nil {
t.Fatal(err)
}
if _, _, err := g.EnterScene(webui); err != nil {
t.Fatal(err)
}
// 桶键 = sig.Label(2) 本身,**不带 "auto:" 前缀**(已用探针实测:
// 登记出来的 label 是 "chan:qq" / "chan:webui")。
if evidenceCount(t, g, NormalizeSceneKey("chan:webui")) == 0 {
t.Fatal("webui 桶的证据没登记上,前提不成立")
}
// qq 第 2 次 → 建出场景
key, _, err := g.EnterScene(qq)
if err != nil {
t.Fatal(err)
}
if key == "" {
t.Fatal("qq 第 2 次应建出场景")
}
// ★ 关键断言:qq 的证据被吸收了,webui 的必须还在
if n := evidenceCount(t, g, NormalizeSceneKey("chan:webui")); n != 1 {
t.Errorf("建 qq 场景时把 webui 桶的证据清了(剩 %d,应为 1)—— "+
"DELETE FROM situation_evidence 是全表清,"+
"别的场景的门槛计数被这次建键随机打断了", n)
}
}
// 被吸收的应该是**本指纹**那个桶。
func TestSceneCreationClearsOwnEvidence(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
qq := mkSig("qq", "", "")
webui := mkSig("webui", "", "")
g.EnterScene(qq)
g.EnterScene(webui)
qqLabel := NormalizeSceneKey(qq.Label(2)) // 桶键不带 auto: 前缀
if evidenceCount(t, g, qqLabel) == 0 {
t.Fatalf("前提不成立:%q 桶应有证据", qqLabel)
}
if _, _, err := g.EnterScene(qq); err != nil {
t.Fatal(err)
}
if n := evidenceCount(t, g, qqLabel); n != 0 {
t.Errorf("场景已成立,%q 桶的证据应被吸收(该场面已长出场景),实际仍为 %d", qqLabel, n)
}
}
// 反复建多个场景,早期桶的证据必须能活到自己的门槛。
// 这是 R5 的真实后果形态:三个通道轮流入,每个都只来过一次,
// 全表清会让它们**永远**攒不到 2 次。
func TestEvidenceSurvivesOtherSceneCreations(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
sigs := map[string]Situation{
"qq": mkSig("qq", "", ""),
"mc": mkSig("mc", "", ""),
"cli": mkSig("cli", "", ""),
"acp": mkSig("acp", "", ""),
"http": mkSig("http", "", ""),
"timer": mkSig("timer", "", ""),
}
// 每个通道轮流来 3 次。若全表清,后到的通道永远攒不够。
for round := 0; round < 3; round++ {
for _, sig := range sigs {
if _, _, err := g.EnterScene(sig); err != nil {
t.Fatal(err)
}
}
}
// 6 个通道都该长出场景
if n := len(sceneKeys(t, g)); n != len(sigs) {
t.Errorf("6 个通道各来 3 次,应长出 %d 个场景,实际 %d: %v\n"+
"全表清证据会让除第一个之外的通道永远攒不到 minSceneEvidence",
len(sigs), n, sceneKeys(t, g))
}
}