Files
HomeAgent/internal/memory/scene_key_test.go
JianFeeeee 1fa9ef68a4 fix(memory): 场景键归一化 + 排除最弱维度,修双胞胎与空转
现网实测:scenes 表 65 行里有 6 组是同一场面的双胞胎键,最严重的
auto:chan:qq+part:morning 累积到 strength=270、6 个 features、
0 条记忆,日志里被"命中"179 次;孪生的 auto:chan:qq_part:morning
则持有 201 条记忆却有 0 个 features(不参与聚类)。两套特征体系
各活各的,谁也发现不了谁。

病因:键构造不唯一。
- Label() 直接用 "+" 拼接且不过 NormalizeSceneKey,而
  EnsureScene / effectiveScenes / RecallByScene 三处都过了归一化。
  "+" 会被 normalizeSceneSegment 归一成 "_",于是两个字符串都合法,
  key UNIQUE 约束拦不住。
- Label(2) 会把权重仅 0.2 的 part(时段)挤进场景身份。实测
  morning 场景吞掉 evening 指纹:共享 chan:qq 权重 1.0、并集含
  part 0.2×2,相似度 1.0/1.4 = 0.714 > joinSceneThreshold 0.5。
  这本就是加权 Jaccard 的正常行为,但键名不该写进时段。
- createSceneLocked 的 "#N" 冲突后缀同样会被归一成 "_",
  造成 auto:chan:mc:event+topic:mc#2 在库、而写侧归一化后去找
  auto:chan:mc:event_topic:mc_2 —— 又一对匹配不上的双胞胎。

修法:
- Label 只取权重 ≥ 0.5 的主导特征,结果过 NormalizeSceneKey;
  全部特征都弱于门槛时退回最强的一批(宁可名字信息量低,也不能
  没有名字 —— 没名字就没有键,场景根本长不出来)。
- createSceneLocked 对 base 再做一次防御性归一化,并在 label 为空
  时拒建无名场景。
- 冲突后缀由 "#" 改为 "."('#' 会被归一化,'.' 是白名单字符)。

判据:新增 scene_key_test.go 6 例,参照物在生产代码之外("同一场面
⇒ 同一个键"这条不变量 + 直查 scenes 表复算行数)。先跑红确认判据
在跑(4 红 1 绿,失败的正是键唯一性/时段污染/证据桶),再改实现。
其中 TestWrittenRefReachableFromItsScene 一开始就是绿的——写侧到读侧
那条路本身是通的,坏的只是键的构造。

记忆 8 包 + agent/core 全绿。
2026-09-26 19:56:28 +08:00

239 lines
7.2 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
// 场景键唯一性的判据(修复 R1/R2 的红测试)。
//
// 背景(生产实测):scenes.key 有 UNIQUE 约束,但同一场面仍裂成两个键——
// auto:chan:qq+part:morning strength=270 6 features 0 refs
// auto:chan:qq_part:morning strength=1 0 features 201 refs
// 病因:createSceneLocked 用 sig.Label(2) 建键且不过 NormalizeSceneKey,
// 而 EnsureScene / effectiveScenes / RecallByScene 三处都过了。
//
// 本组测试的判据在**生产代码之外**:期望值是「同一场面 ⇒ 同一个键」这条
// 不变量,不引用任何被测实现细节。判据自身也做了双向检查:
// 先用「改实现 ⇒ 必须变红」验证过它真的在跑。
import (
"os"
"strings"
"testing"
)
// sigWithPart 只有 chan + part 两个特征(part 权重 0.2,是最弱维度)。
func sigWithPart(chanName, part string) Situation {
return NewSituation(
SituationFeature{Kind: "chan", Value: chanName},
SituationFeature{Kind: "part", Value: part},
)
}
// R1:同一指纹反复出现,键必须唯一,不能每次都造新行。
func TestSceneKeyIsUniqueForSameSituation(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
sig := mkSig("qq", "", "")
// 连喂 6 次。minSceneEvidence=2 ⇒ 第 2 次建场景、之后只强化。
// 要断言的**不是**哪一次 created,而是:全程只允许存在一个键。
keys := map[string]int{}
for i := 0; i < 6; i++ {
key, _, err := g.EnterScene(sig)
if err != nil {
t.Fatalf("第 %d 次 EnterScene 出错: %v", i+1, err)
}
if key == "" {
continue
}
keys[key]++
}
if len(keys) == 0 {
t.Fatal("6 次重复交互后仍未建出场景,与 minSceneEvidence=2 的设计矛盾")
}
if len(keys) > 1 {
t.Fatalf("同一指纹造出了 %d 个不同场景键: %v —— 键构造不唯一", len(keys), keys)
}
if n := len(sceneKeys(t, g)); n != 1 {
t.Fatalf("scenes 表里有 %d 行,应为 1(重复交互不得增殖场景行)", n)
}
}
// 门槛本身:第 1 次只留足迹、第 2 次建场景。这是 minSceneEvidence 的语义,
// 单独钉住,免得修键时把门槛一起改掉。
func TestSceneEvidenceThresholdIsTwo(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
sig := mkSig("qq", "", "")
if key, _, err := g.EnterScene(sig); err != nil {
t.Fatal(err)
} else if key != "" {
t.Fatalf("第 1 次就建了场景 %q,minSceneEvidence=2 失效", key)
}
key, created, err := g.EnterScene(sig)
if err != nil {
t.Fatal(err)
}
if key == "" || !created {
t.Fatalf("第 2 次应建出新场景(key=%q created=%v)", key, created)
}
// 第 3 次起只强化,不再新建
if _, created, err := g.EnterScene(sig); err != nil {
t.Fatal(err)
} else if created {
t.Fatal("第 3 次不该再新建场景")
}
}
// R1(生产现场形态):带 + 的键与带 _ 的键必须归一到同一个。
// 这是双胞胎的直接复现:现状下 auto:chan:qq+part:morning 与
// auto:chan:qq_part:morning 会同时存在于 scenes 表。
func TestSceneKeyNormalized_NoPlusVersusUnderscore(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
// 造两次:强特征相同、只有 part 不同(现实中「早上在 QQ」与「晚上在 QQ」)
for _, part := range []string{"morning", "evening"} {
sig := sigWithPart("qq", part)
for i := 0; i < 3; i++ {
if _, _, err := g.EnterScene(sig); err != nil {
t.Fatalf("EnterScene(%s) 出错: %v", part, err)
}
}
}
keys := sceneKeys(t, g)
if len(keys) == 0 {
t.Fatal("未建出任何场景")
}
for _, k := range keys {
if strings.Contains(k, "+") {
t.Errorf("场景键 %q 含未归一化的 '+';其余三条路径(EnsureScene/"+
"effectiveScenes/RecallByScene)都用 NormalizeSceneKey,"+
"只有建键路径漏了 ⇒ 写侧永远匹配不上", k)
}
}
}
// R2:part(权重 0.2,最弱维度)不该成为场景身份的一部分。
func TestSceneKeyExcludesWeakPartFeature(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
// 只有 chan 一个强特征,part 必然挤进 Label(2) 的第二位。
for i := 0; i < 3; i++ {
if _, _, err := g.EnterScene(sigWithPart("qq", "morning")); err != nil {
t.Fatal(err)
}
}
keys := sceneKeys(t, g)
if len(keys) == 0 {
t.Fatal("未建出场景")
}
for _, k := range keys {
if strings.Contains(k, "part") {
t.Errorf("场景键 %q 把时段(part, 权重 0.2)写进了身份。"+
"时段是最弱维度:生产库实测出现「morning」场景吞掉 evening 指纹"+
"(共享 chan:qq,相似度 1.0/1.4=0.714 > 阈值 0.5)", k)
}
}
}
// R2 的另一半:part 不该影响「是否建场景」的门槛桶。
// 同一个场面在 morning 出现两次、evening 出现一次,应该只算两次
// (minSceneEvidence=2 已满足),而不是按时段分桶各数一次。
func TestEvidenceBucketIgnoresPart(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
// 第一轮 morning:只登记足迹,不建场景
if key, _, err := g.EnterScene(sigWithPart("qq", "morning")); err != nil {
t.Fatal(err)
} else if key != "" {
t.Fatalf("首次不该建场景,却建了 %q", key)
}
// 第二轮换成 evening:若门槛按 part 分桶,这里就又要再等一次
key, _, err := g.EnterScene(sigWithPart("qq", "evening"))
if err != nil {
t.Fatal(err)
}
if key == "" {
t.Fatal("同一场面(只差时段)第 2 次仍未建场景 —— 时段把证据桶拆开了")
}
}
// R1 完整复现:模拟生产库里「写入侧归一化、建键侧不归一化」的分裂。
// 断言:写侧挂的记忆,最终能通过读侧召回回到同一个场景。
func TestWrittenRefReachableFromItsScene(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
sig := mkSig("qq", "", "")
var key string
for i := 0; i < 3 && key == ""; i++ {
k, _, err := g.EnterScene(sig)
if err != nil {
t.Fatal(err)
}
key = k
}
if key == "" {
t.Fatal("未建出场景")
}
// 写侧走 effectiveScenes(它会归一化)——这正是生产代码的路径
ec, rc, err := g.Commit([]Triple{{
Subject: "老大", Relation: "偏好", Object: "咖啡",
Scenes: []string{key}, // 模型/内核给的键,可能带 + 或 _
}}, "s1", 0)
if err != nil {
t.Fatal(err)
}
if ec == 0 || rc == 0 {
t.Fatal("三元组未写入")
}
// 读侧也走归一化(RecallByScene 内部会做)
res, err := g.RecallByScene([]string{key}, 8)
if err != nil {
t.Fatal(err)
}
if len(res.Relations) == 0 {
t.Fatalf("刚写进场景 %q 的关系,经同键召回却取不回 —— "+
"建键与写/读两侧对「同一个键」的认定不一致", key)
}
}
// sceneKeys 直查 scenes 表(不引用被测统计函数,独立复算)。
func sceneKeys(t *testing.T, g *GraphDB) []string {
t.Helper()
rows, err := g.db.Query(`SELECT key FROM scenes ORDER BY id`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var out []string
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
t.Fatal(err)
}
out = append(out, k)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return out
}