mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 21:03:16 +00:00
现网实测: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 全绿。
239 lines
7.2 KiB
Go
239 lines
7.2 KiB
Go
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
|
||
}
|