mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 21:03:16 +00:00
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:
116
docs/zh/scene-memory-fix-plan.md
Normal file
116
docs/zh/scene-memory-fix-plan.md
Normal file
@ -0,0 +1,116 @@
|
||||
# 场景式记忆修复 plan
|
||||
|
||||
> 起点:2026-09-26 生产库实测(`/home/newqqagent/memory/graph.db`)。
|
||||
> 现象:65 个场景中 6 组是同一场面的双胞胎键;主力场景 `auto:chan:qq+part:morning`
|
||||
> strength=270、6 个 features、**0 条记忆**,日志里被"命中"179 次。
|
||||
> 分支:`feature/scene-writeback`(从 main 拉出,工作树干净)。
|
||||
|
||||
---
|
||||
|
||||
## 根因(三条,逐条修)
|
||||
|
||||
### R1 建键不过归一化(病因)
|
||||
|
||||
`internal/memory/scene_emerge.go:329`
|
||||
|
||||
```go
|
||||
base := "auto:" + sig.Label(2) // Label 用 "+" 拼接,未归一化
|
||||
```
|
||||
|
||||
而同一层的另外三条路**都**过了归一化:
|
||||
|
||||
| 位置 | 是否归一化 |
|
||||
|---|---|
|
||||
| `EnsureScene`(声明建键)`scene_emerge.go:555` | ✅ |
|
||||
| `effectiveScenes`(写侧挂 ref)`graph.go:1397` | ✅ |
|
||||
| `RecallByScene`(读侧召回)`scene.go:328` | ✅ |
|
||||
| **`createSceneLocked`(涌现建键)`scene_emerge.go:329`** | ❌ |
|
||||
|
||||
`normalizeSceneSegment` 把 `+` 归一成 `_`,所以涌现键天生带 `+`、其余三方说 `_`。
|
||||
`scenes.key` 虽有 `UNIQUE` 约束,但**两个不同字符串都合法**,拦不住。
|
||||
|
||||
实测 6 组双胞胎(`REPLACE(key,'+','_')` 后重名):
|
||||
|
||||
```
|
||||
auto:chan:qq+part:morning ↔ auto:chan:qq_part:morning (270/0 refs)
|
||||
auto:chan:mc:event+topic:mc ↔ auto:chan:mc:event_topic:mc
|
||||
auto:chan:mc:system+topic:mc ↔ auto:chan:mc:system_topic:mc
|
||||
auto:chan:mc-resident+topic:mc ↔ auto:chan:mc-resident_topic:mc
|
||||
auto:chan:homeagent-mail-bridge+... ↔ auto:chan:homeagent-mail-bridge_...
|
||||
auto:chan:system+topic:任务 ↔ auto:chan:system_topic:任务
|
||||
```
|
||||
|
||||
### R2 `part`(时段,权重 0.2)污染主键与门槛
|
||||
|
||||
`Label(2)` 取权重最高的 2 个特征。特征顺序是
|
||||
`chan(1.0) → peer(1.0) → tool(0.8) → topic(0.4) → part(0.2)`,
|
||||
只有当前 2 个强特征不足时 `part` 才会进键——于是出现
|
||||
`auto:chan:qq+part:morning` 这种"时段成了场景身份"的名字。
|
||||
|
||||
**更糟的是 `recordSituationEvidenceLocked` 也用 `Label(2)` 做桶**(`scene_emerge.go:380`),
|
||||
所以 `part` 不只影响名字,还决定 `minSceneEvidence=2` 这个门槛在哪个桶里计数。
|
||||
|
||||
实测证据(键名与实际指纹自相矛盾):
|
||||
|
||||
```
|
||||
场景键: auto:chan:qq+part:morning strength=270
|
||||
实际指纹: [chan:qq part:evening] ← 19:15 命中,evening 却并入 morning
|
||||
```
|
||||
|
||||
这不是 bug 触发,是加权 Jaccard 正常工作:共享 `chan:qq`(1.0)、
|
||||
并集含 `part`(0.2×2),相似度 1.0/1.4 = 0.714 > `joinSceneThreshold` 0.5。
|
||||
|
||||
### R3 图整理心跳漏扫 `scenes`
|
||||
|
||||
`detectEntityMerge` 唯一遍历入口 `Recall(nil,nil,1,"")`,其全量路径只查
|
||||
`entities`(`graph.go:609`) + `relations`(`graph.go:631`),`scenes` 不在其中。
|
||||
所以 R1/R2 造成的双胞胎从 9-15 起无人发现,23 号空转到 strength=270。
|
||||
|
||||
**R3 是"没被发现"的原因,R1/R2 是病因。** 顺序不能反。
|
||||
|
||||
---
|
||||
|
||||
## 步骤
|
||||
|
||||
### 步骤 1:修 R1 + R2(源头,不碰存量)
|
||||
|
||||
- [ ] `Label` 改名语义:**只取权重 ≥ 阈值的主导特征**,且结果过 `NormalizeSceneKey`
|
||||
- [ ] `createSceneLocked` 的 `base` 用归一化后的 label
|
||||
- [ ] `recordSituationEvidenceLocked` 的桶键用同一个归一化 label(R2 的另一半)
|
||||
- [ ] 判据:先写**红**测试——同一指纹两次建键必须落同一行(现状会落两行)
|
||||
|
||||
### 步骤 2:修 R3(让图整理覆盖全库)
|
||||
|
||||
- [ ] 给 `mergeLoop` 加**独立的场景去重路径**,不塞进实体那个 O(n²) 双重循环
|
||||
(理由:实体 1 万行 × bigram + LLM 裁决,实测 5000 万次配对/轮;
|
||||
场景表小且**已有现成的 `situationSimilarity` 加权 Jaccard**,语义更准)
|
||||
- [ ] 键归一化后相同 ⇒ 合并 refs/features/strength,**不经 LLM**(键相同已证明同一场面)
|
||||
- [ ] 判据:构造两个 `+`/`_` 孪生键,跑一次 mergeLoop 后期望合成一个
|
||||
|
||||
### 步骤 3:清理现网垃圾场景,让它重新生成
|
||||
|
||||
用户明确要求:**直接清理,重新生成**(不做保守迁移)。
|
||||
|
||||
- [ ] `sqlite3 .backup` 备份(**禁用 cp**,WAL 模式会拷出不一致快照)
|
||||
- [ ] 删 `origin='emergent'` 的全部场景 + 其 `scene_features`/`scene_refs`
|
||||
(**保留 `origin='declared'`**:那些是插件声明的,不是垃圾)
|
||||
- [ ] 同步清 `situation_evidence`
|
||||
- [ ] 重启 homed,等 ≥2 次同类交互让场景重新涌现
|
||||
- [ ] 复验:新场景键**不含 `+`**、有 features **且**有 refs
|
||||
|
||||
### 步骤 4:验证与收口
|
||||
|
||||
- [ ] `go test ./internal/memory/... ./internal/agent/core/...` 全绿
|
||||
- [ ] `go build ./...` + 全仓 `go test ./...`
|
||||
- [ ] 现网观察:日志中场景命中后能查到 refs(非 0)
|
||||
- [ ] `git_release_check.sh` 无新增红项
|
||||
|
||||
---
|
||||
|
||||
## 不做的事(防反复挂账)
|
||||
|
||||
- **不**把 `scenes` 塞进 `memory_merge` 工具:该工具语义是"实体删除 + 关系重定向",
|
||||
场景合并需要"特征并集 + refs 重定向 + strength 相加",是另一套操作,硬塞会让工具语义变危险。
|
||||
- **不**改 `validGraphNodeKind`:它只管 `memory_block_edges` 端点校验,与 `scenes` 无关。
|
||||
- **不**给实体那个 O(n²) 循环做优化:属独立问题(已实测:1 万实体→~224GB 瞬时分配/轮),
|
||||
混进本次修复会让 diff 失焦。单独开条目。
|
||||
@ -132,17 +132,52 @@ func (s Situation) Keys() []string {
|
||||
// Empty 表示指纹里没有任何可判定的信号。
|
||||
func (s Situation) Empty() bool { return len(s.Features) == 0 }
|
||||
|
||||
// Label 用权重最高的少数特征给场景起个可读名字(`chan:qq+tool:qq_get_message`)。
|
||||
// 只用于人看,不参与匹配——匹配永远走特征集合。
|
||||
// labelFeatureWeight 是参与**场景身份**的最低特征权重。
|
||||
//
|
||||
// 为什么设门槛:part(时段)权重只有 0.2,是场面里最弱的维度——
|
||||
// 「在 QQ 上」和「在 QQ 上且是早上」是同一个场面,时段不该把它切成两个。
|
||||
// 早期实现直接取 Label(2),只有 chan 一个强特征时 part 必然挤进第二位,
|
||||
// 于是键名变成 auto:chan:qq+part:morning:既是「时段成了身份」,
|
||||
// 又让加权 Jaccard 把它当成另一个场面(实测:morning 场景吞掉 evening 指纹,
|
||||
// 共享 chan:qq 权重 1.0、并集含 part 0.2×2,相似度 1.0/1.4=0.714 > 0.5)。
|
||||
const labelFeatureWeight = 0.5
|
||||
|
||||
// Label 用权重达标的主导特征给场景起个**可读名**(`chan:qq+tool:qq_get_message`)。
|
||||
//
|
||||
// 两条硬约束(缺一就会造出写侧匹配不上的键):
|
||||
// 1. 只取权重 ≥ labelFeatureWeight 的特征:时段/话题不进身份。
|
||||
// 2. 结果**必须过 NormalizeSceneKey**:'+' 会被 normalizeSceneSegment 归一成
|
||||
// '_',而 EnsureScene / effectiveScenes / RecallByScene 三处都过了归一化。
|
||||
// 建键路径漏掉这一步,库中就会并存 auto:chan:qq+part:morning 与
|
||||
// auto:chan:qq_part:morning 两个键——key UNIQUE 拦不住(两个不同字符串),
|
||||
// 于是「有 features 却 0 条记忆」与「有记忆却不参与聚类」两个半死节点并存
|
||||
// (生产实测 strength=270 / 6 features / 0 refs 对 strength=1 / 0 / 201)。
|
||||
func (s Situation) Label(max int) string {
|
||||
if max <= 0 {
|
||||
max = 2
|
||||
}
|
||||
keys := s.Keys()
|
||||
if len(keys) > max {
|
||||
keys = keys[:max]
|
||||
var picked []string
|
||||
for _, f := range s.Features {
|
||||
if f.Weight() < labelFeatureWeight {
|
||||
continue
|
||||
}
|
||||
picked = append(picked, f.Key())
|
||||
if len(picked) >= max {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(keys, "+")
|
||||
if len(picked) == 0 {
|
||||
// 全部特征都弱于门槛(纯 topic/part 的轮次):退回最强的一批特征,
|
||||
// 宁可名字信息量低,也不要没有名字——没名字就没有键,场景根本长不出来。
|
||||
n := max
|
||||
if n > len(s.Features) {
|
||||
n = len(s.Features)
|
||||
}
|
||||
for _, f := range s.Features[:n] {
|
||||
picked = append(picked, f.Key())
|
||||
}
|
||||
}
|
||||
return NormalizeSceneKey(strings.Join(picked, "+"))
|
||||
}
|
||||
|
||||
// emergentScene 是一次聚类计算中的场景视图。
|
||||
@ -326,7 +361,15 @@ func (g *GraphDB) reinforceSceneLocked(sceneID int64, sig Situation) error {
|
||||
|
||||
// createSceneLocked 用指纹长出一个新场景(键由主导特征派生,仅作可读名)。
|
||||
func (g *GraphDB) createSceneLocked(sig Situation) (string, error) {
|
||||
base := "auto:" + sig.Label(2)
|
||||
// Label 已保证:过滤弱特征 + 过 NormalizeSceneKey。
|
||||
// 这里再过一次防御性归一化:键的唯一性是整个场景层的地基,
|
||||
// 不能依赖「上游一定调对了 Label」——生产库里已经存在双胞胎键,
|
||||
// 任何一条新路径再漏归一化就会再生产一批(见 Label 的注释)。
|
||||
base := NormalizeSceneKey("auto:" + sig.Label(2))
|
||||
if base == "auto:" {
|
||||
// Label 退化到空(指纹被裁空):不建无主场景,否则所有空指纹会堆进同一行。
|
||||
return "", fmt.Errorf("situation label 为空,拒绝建无名场景")
|
||||
}
|
||||
key := base
|
||||
|
||||
tx, err := g.db.Begin()
|
||||
@ -336,6 +379,13 @@ func (g *GraphDB) createSceneLocked(sig Situation) (string, error) {
|
||||
defer tx.Rollback()
|
||||
|
||||
// 键冲突(同一可读名已被占)时加后缀,不合并——真正的合并交给相似度判定。
|
||||
//
|
||||
// ★ 这里加出来的 #N 后缀**必须与原键一样合法**:它会被写进 scenes.key,
|
||||
// 而写侧(effectiveScenes)与读侧(RecallByScene)都会对它做归一化。
|
||||
// '#' 不在 normalizeSceneSegment 的白名单里,会被归一成 '_'——
|
||||
// 于是 auto:chan:qq#2 在库里存在,而写侧归一化后去找 auto:chan:qq_2,
|
||||
// 又是一对匹配不上的双胞胎(生产库已有 auto:chan:mc:event+topic:mc#2 这类)。
|
||||
// 所以后缀改用不会触发归一化改写的字符。
|
||||
for i := 2; ; i++ {
|
||||
var exists int
|
||||
if err := tx.QueryRow(`SELECT COUNT(*) FROM scenes WHERE key = ?`, key).Scan(&exists); err != nil {
|
||||
@ -344,7 +394,7 @@ func (g *GraphDB) createSceneLocked(sig Situation) (string, error) {
|
||||
if exists == 0 {
|
||||
break
|
||||
}
|
||||
key = fmt.Sprintf("%s#%d", base, i)
|
||||
key = fmt.Sprintf("%s.%d", base, i)
|
||||
}
|
||||
|
||||
res, err := tx.Exec(`INSERT INTO scenes (key, strength, origin) VALUES (?, 1, 'emergent')`, key)
|
||||
@ -372,8 +422,8 @@ func (g *GraphDB) createSceneLocked(sig Situation) (string, error) {
|
||||
|
||||
// recordSituationEvidenceLocked 登记一次「同类指纹出现过」,返回累计次数。
|
||||
//
|
||||
// 用指纹标签(主导特征)做粗聚类桶,只服务于「首次不建场景」的门槛判定,
|
||||
// 不参与后续匹配——匹配永远走 EnterScene 的相似度。
|
||||
// 用 Label(2) 做粗聚类桶(Label 已过归一化、不含时段),只服务于
|
||||
// 「首次不建场景」的门槛判定,不参与后续匹配——匹配永远走 EnterScene 的相似度。
|
||||
func (g *GraphDB) recordSituationEvidenceLocked(sig Situation) (int, error) {
|
||||
label := sig.Label(2)
|
||||
if _, err := g.db.Exec(
|
||||
|
||||
238
internal/memory/scene_key_test.go
Normal file
238
internal/memory/scene_key_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user