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 全绿。
This commit is contained in:
JianFeeeee
2026-09-26 19:56:28 +08:00
parent dc84e276f5
commit 833dc7f30f
3 changed files with 414 additions and 10 deletions

View File

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