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。
This commit is contained in:
JianFeeeee
2026-09-26 20:16:11 +08:00
parent 8887e06274
commit 758ec11832
7 changed files with 604 additions and 5 deletions

View File

@ -157,8 +157,11 @@ RecallPolicy 召不召回记忆(只读,默认开)
一个不参与场面识别的通道不该留下时段噪声) 一个不参与场面识别的通道不该留下时段噪声)
- 声明路 `sceneKeysFor` 同样受 `none` 约束 - 声明路 `sceneKeysFor` 同样受 `none` 约束
- [ ] 判据:`none` 通道连续 5 次交互,`scenes` 表行数不变 - [ ] 判据:`none` 通道连续 5 次交互,`scenes` 表行数不变
- [ ] 存量标注:给 `system` / `kernel` / `timer` / `healthcheck` 等内部信噪通道 - [x] **存量标注:不做**(用户裁定 2026-09-26:「所有都默认开启,因为多写无影响,
标 `ScenePolicyNone`(**逐个确认后再标**,不批量猜) 少写会缺场景」)。实测支持:8 个 0-refs 通道合计 70 strength、0 条记忆,
召回返回空;且 declared 场景**不进**相似度空间
(`loadEmergentScenesLocked` 只取 `origin='emergent'`),
故多写对聚类零影响。声明项作为「插件将来确实需要时」的闸门保留。
### 步骤 3:修 R3(让图整理覆盖全库) ### 步骤 3:修 R3(让图整理覆盖全库)

View File

@ -106,6 +106,7 @@ func (a *Agent) mergeLoop() {
case <-ticker.C: case <-ticker.C:
log.Printf("[agent] heartbeat merge tick") log.Printf("[agent] heartbeat merge tick")
a.detectEntityMerge() a.detectEntityMerge()
a.dedupeScenes()
case <-a.ctx.Done(): case <-a.ctx.Done():
return return
} }
@ -312,6 +313,31 @@ func (a *Agent) detectEntityMerge() {
} }
} }
// dedupeScenes 是图整备的场景侧去重:把「同一个场面的两个键」合成一个。
//
// 为什么与 detectEntityMerge 分开而不塞进它的双重循环:
// - 实体:全库两两 bigram + LLM 裁决。1 万实体实测 5000 万次配对、
// ~224GB 瞬时分配每轮(见 plan「不做的事」),已是独立问题。
// - 场景:判重口径是**归一化后是否同名**——同名即同一场面,**不需要 LLM
// 裁决**(键相同本身就是证据)。且 declared 场景不进相似度空间,
// 场景之间「像不像」由 EnterScene 的聚类负责,不是这里的事。
//
// 所以这里只做确定性的同键合并,不做相似度合并:把「chan:qq 与
// chan:webui 很像」也合并是危险的,那会把不同场面糊成一个。
func (a *Agent) dedupeScenes() {
if a.memory == nil {
return
}
merged, err := a.memory.DedupeScenes()
if err != nil {
log.Printf("[agent] 场景去重失败(下轮重试): %v", err)
return
}
if merged > 0 {
log.Printf("[agent] 场景去重:合并 %d 组同键场景(同一场面的重复键)", merged)
}
}
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
// 关系复审:GraphDB → ClearSentenceID → CleanupOrphanedSentences // 关系复审:GraphDB → ClearSentenceID → CleanupOrphanedSentences
// ────────────────────────────────────────────── // ──────────────────────────────────────────────

View File

@ -580,3 +580,146 @@ func (g *GraphDB) TagSceneDocument(sceneKey, docID string) error {
} }
return tx.Commit() return tx.Commit()
} }
// ──────────────────────────────────────────────
// 场景去重(整备):把「同一个场面的两个键」合成一个
// ──────────────────────────────────────────────
// DedupeScenes 合并归一化后同名的场景,返回合并组数。
//
// 存在的原因:scenes.key 有 UNIQUE 约束,但**归一化口径曾经不统一**——
// 建键路径用 "auto:" + Label(2) 而 Label 拼的是 "+",不过 NormalizeSceneKey;
// 而写侧(effectiveScenes)、读侧(RecallByScene)、声明建键(EnsureScene)
// 三处都过了归一化。于是 "+" 与 "_" 成为两个都合法的主键,UNIQUE 拦不住:
//
// auto:chan:qq+part:morning strength=270 6 features 0 refs
// auto:chan:qq_part:morning strength=1 0 features 201 refs
//
// 两个节点互不可见:聚类只读 scene_features,所以 0-features 的那个
// 永远不被看见;而 0-refs 的那个收不到任何写侧记忆。实测这对双胞胎
// 从建库起累积到 strength=270 都没人发现——因为图整理心跳(mergeLoop)
// 的遍历入口 Recall(nil,nil,1,"") 只查 entities 与 relations,scenes
// 不在其中。
//
// 合并口径:**只有归一化后完全同名才算重复**。相似但不同的场面
// (chan:qq 与 chan:webui)绝不合并——去重不是"把像的一律合并"。
// 场景之间的相似度判定是 EnterScene 的聚类职责,那是另一件事。
//
// 存活规则:保留 id 最小的那一行(先来者),其余并入它。
// 强度相加、特征取并集(权重取大)、引用全部重定向。
// 跨 origin 也合:现网存在 origin='emergent' 却长得像声明键的
// chan:context_archived。
func (g *GraphDB) DedupeScenes() (int, error) {
g.mu.Lock()
defer g.mu.Unlock()
rows, err := g.db.Query(`SELECT id, key FROM scenes ORDER BY id`)
if err != nil {
return 0, err
}
type row struct {
id int64
key string
}
var all []row
for rows.Next() {
var r row
if err := rows.Scan(&r.id, &r.key); err != nil {
rows.Close()
return 0, err
}
all = append(all, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
if len(all) < 2 {
return 0, nil
}
// 按归一化后的键分组,组内 id 最小者为存活者。
groups := make(map[string][]row)
order := make([]string, 0, len(all))
for _, r := range all {
nk := NormalizeSceneKey(r.key)
if nk == "" {
// 键归一化后为空:无法判定它与谁重复,跳过(不擅自删数据)。
continue
}
if _, seen := groups[nk]; !seen {
order = append(order, nk)
}
groups[nk] = append(groups[nk], r)
}
merged := 0
for _, nk := range order {
gp := groups[nk]
if len(gp) < 2 {
continue
}
keep := gp[0] // ORDER BY id ⇒ 最早创建的那行
tx, err := g.db.Begin()
if err != nil {
return merged, err
}
err = func() error {
for _, dup := range gp[1:] {
// 强度相加。
if _, err := tx.Exec(
`UPDATE scenes SET strength = COALESCE(strength,1) +
COALESCE((SELECT strength FROM scenes WHERE id = ?), 0),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`, dup.id, keep.id); err != nil {
return err
}
// 特征取并集,权重取大。
if _, err := tx.Exec(
`INSERT INTO scene_features (scene_id, feature, weight)
SELECT ?, feature, weight FROM scene_features WHERE scene_id = ?
ON CONFLICT(scene_id, feature) DO UPDATE
SET weight = MAX(weight, excluded.weight)`,
keep.id, dup.id); err != nil {
return err
}
if _, err := tx.Exec(
`DELETE FROM scene_features WHERE scene_id = ?`, dup.id); err != nil {
return err
}
// 引用重定向。UNIQUE(scene_id,kind,ref_id,ref_text) 会与存活者
// 上的同一条冲突——冲突即同一条记忆,取权重大的那条。
if _, err := tx.Exec(
`INSERT INTO scene_refs (scene_id, kind, ref_id, ref_text, weight)
SELECT ?, kind, ref_id, ref_text, weight FROM scene_refs WHERE scene_id = ?
ON CONFLICT(scene_id, kind, ref_id, ref_text) DO UPDATE
SET weight = MAX(weight, excluded.weight)`,
keep.id, dup.id); err != nil {
return err
}
if _, err := tx.Exec(
`DELETE FROM scene_refs WHERE scene_id = ?`, dup.id); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM scenes WHERE id = ?`, dup.id); err != nil {
return err
}
merged++
}
// 存活者的键也归一化,避免下次又认不出自己。
if _, err := tx.Exec(`UPDATE scenes SET key = ? WHERE id = ?`, nk, keep.id); err != nil {
return err
}
return nil
}()
if err != nil {
tx.Rollback()
return merged, err
}
if err := tx.Commit(); err != nil {
return merged, err
}
}
return merged, nil
}

View File

@ -0,0 +1,285 @@
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)
}
}

View File

@ -410,8 +410,17 @@ func (g *GraphDB) createSceneLocked(sig Situation) (string, error) {
return "", err return "", err
} }
} }
// 场景成立后,把此前登记的同类线索清掉(它们已被这次长出吸收) // 场景成立后,**只清本指纹那个桶**的线索(它们已被这次长出吸收)。
if _, err := tx.Exec(`DELETE FROM situation_evidence`); err != nil { //
// 曾经是 `DELETE FROM situation_evidence`(全表清),后果不是「多清一点」:
// 多通道共用一个库,qq 的场景一长出来,就把 mc / webui / cli 尚未攒够
// minSceneEvidence 的证据一并抹掉——它们的计数被反复清零,于是
// **永远**攒不到 2 次,场景永远长不出来。实测:6 个通道各来 3 次,
// 只长出 2 个场景。
//
// ★ 桶键是 sig.Label(2) 本身,**不带 "auto:" 前缀**(见
// recordSituationEvidenceLocked)——base 是带前缀的场景键,两者不是一回事。
if _, err := tx.Exec(`DELETE FROM situation_evidence WHERE label = ?`, sig.Label(2)); err != nil {
return "", err return "", err
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {

View File

@ -0,0 +1,129 @@
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))
}
}

View File

@ -89,7 +89,11 @@ func ValidRecallPolicy(policy string) bool {
// 与 ContextPolicy 刻意相反(同为破坏性操作,那里是默认关)。 // 与 ContextPolicy 刻意相反(同为破坏性操作,那里是默认关)。
// //
// 该关的典型是纯内部通道:system(内核自循环)、kernel、timer、healthcheck。 // 该关的典型是纯内部通道:system(内核自循环)、kernel、timer、healthcheck。
// 它们每次触发都在撑一个「场面」,会把不相干的交互聚到一起。 // 但**现网不标任何一个**(2026-09-26 裁定):实测这些 0-refs 通道合计 70
// strength、0 条记忆,场景召回返回空;而 declared 场景不进相似度空间
// (loadEmergentScenesLocked 只取 origin='emergent'),多写对聚类零影响。
// 「多写无影响、少写会缺场景」——默认 auto 保持开,声明项只作为插件
// 将来确实需要时的闸门。
const ( const (
ScenePolicyAuto = "auto" ScenePolicyAuto = "auto"
ScenePolicyNone = "none" ScenePolicyNone = "none"