mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 01:18:08 +00:00
按用户确认的形态:**独立存储实例**(不给共享记忆层加 space 列)。
## LightMemory:子的图记忆装配(设计 §5.6)
temp 实例(独立存储,读写) ← 子的一切图记忆写入落这里,与子同生共死
主库受限句柄(只读) ← 子只能读(query_only 结构性拒绝写入)
子的查询 = 两实例各查一次 + **应用层合并**(并集)
- 写入**只落 temp**(`Commit` 不接受 main 方向)
- 并集合并规则:实体按**名字**去重(同名保留 mention_count 较大者)、
关系按 (源名, 关系, 目标名) 去重;结果排序确定(便于断言与展示稳定)
- 单侧查询失败不影响另一侧(只有两侧都失败才报错)
- `AllowWrite=false`(用户给的备选简化):**没有 temp 实例**,子对图记忆完全只读,
写入被拒;读主库照常
## ExportTriples + 回收合入(设计 §9,N2d 数据面)
- `GraphDB.ExportTriples(limit)`:导出**活跃**三元组,把实体名一并带出
⇒ 合入侧直接复用 `Commit`(按实体名 upsert + 关系唯一约束)
- 回收主流程:子写 temp → 父导出 → **父选哪几条** → 写进 main。
未选中的**不进**主库;重复收割**幂等**(不产生重复实体)
## 验收(7 项新测试)
`light_memory_test.go`(5):
- 写只落 temp、查询是并集、主库无子的痕迹
- **两个子的 temp 互不可见**(只有 main 共享)
- 写禁用时完全只读(无 temp 实例、写入被拒、读照常)
- 并集去重(同名实体只出现一次)
- 合并确定性(去重 + 排序 + mention_count 取大)
`reclaim_test.go`(2):
- 导出只含活跃关系且带实体名、limit 生效
- 回收主流程(选中的进主库、未选中的不进、重复收割幂等)
全仓 go test ./... 37 包 ok / 0 FAIL。
209 lines
6.3 KiB
Go
209 lines
6.3 KiB
Go
package memory
|
||
|
||
// N2a 第二块砖:轻量内核的图记忆装配(temp 可写 + 主库只读 + 并集查询)。
|
||
//
|
||
// 设计:docs/zh/resident-subagent-design.md §5.6。
|
||
// 关键性质:
|
||
// ① 子写入只落 temp(写不进展主库)—— main 是受限句柄,结构性拒绝;
|
||
// ② 子查询 = temp ∪ main;
|
||
// ③ 两个子之间 temp 互不可见;
|
||
// ④ allowTempGraphWrite=false 时子对图记忆完全只读(没有 temp 实例)。
|
||
|
||
import (
|
||
"path/filepath"
|
||
"testing"
|
||
)
|
||
|
||
// newMainWith 建一个主库并写入若干三元组。
|
||
func newMainWith(t *testing.T, dir string, triples ...Triple) *GraphDB {
|
||
t.Helper()
|
||
main, err := NewGraphDB(filepath.Join(dir, "main.db"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(triples) > 0 {
|
||
if _, _, err := main.Commit(triples, "sess", 1); err != nil {
|
||
t.Fatalf("主库写入失败: %v", err)
|
||
}
|
||
}
|
||
return main
|
||
}
|
||
|
||
func names(entities []Entity) []string {
|
||
out := make([]string, 0, len(entities))
|
||
for _, e := range entities {
|
||
out = append(out, e.Name)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func hasName(entities []Entity, name string) bool {
|
||
for _, e := range entities {
|
||
if e.Name == name {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// 子写入只落 temp;查询是 temp ∪ main。
|
||
func TestLightMemory_WriteGoesToTempReadIsUnion(t *testing.T) {
|
||
dir := t.TempDir()
|
||
main := newMainWith(t, dir, Triple{Subject: "张三", Relation: "任职于", Object: "甲公司"})
|
||
defer main.Close()
|
||
|
||
light, err := NewLightMemory(main, filepath.Join(dir, "sub-1.db"), true)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer light.Close()
|
||
|
||
// 子写自己的发现。
|
||
if _, _, err := light.Commit([]Triple{{Subject: "李四", Relation: "任职于", Object: "乙公司"}}, "sess", 1); err != nil {
|
||
t.Fatalf("子写入 temp 应成功: %v", err)
|
||
}
|
||
|
||
// 查询能同时看到 main(张三)与 temp(李四)。
|
||
res, err := light.Recall([]string{"张三", "李四"}, nil, 1, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !hasName(res.Entities, "张三") {
|
||
t.Fatalf("子应看得到主库内容,实际 %v", names(res.Entities))
|
||
}
|
||
if !hasName(res.Entities, "李四") {
|
||
t.Fatalf("子应看得到自己 temp 的内容,实际 %v", names(res.Entities))
|
||
}
|
||
|
||
// 主库**没有**被写入(子改不了 main)。
|
||
mainOnly, err := main.Recall([]string{"李四"}, nil, 1, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if hasName(mainOnly.Entities, "李四") {
|
||
t.Fatalf("主的图记忆里不该出现子才知道的实体:%v", names(mainOnly.Entities))
|
||
}
|
||
}
|
||
|
||
// 两个子之间 temp 互不可见(只有 main 共享)。
|
||
func TestLightMemory_ChildrenTempsAreIsolated(t *testing.T) {
|
||
dir := t.TempDir()
|
||
main := newMainWith(t, dir, Triple{Subject: "共享", Relation: "属于", Object: "主库"})
|
||
defer main.Close()
|
||
|
||
a, err := NewLightMemory(main, filepath.Join(dir, "sub-a.db"), true)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer a.Close()
|
||
b, err := NewLightMemory(main, filepath.Join(dir, "sub-b.db"), true)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer b.Close()
|
||
|
||
if _, _, err := a.Commit([]Triple{{Subject: "A的秘密", Relation: "仅属于", Object: "A"}}, "s", 1); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
resB, err := b.Recall([]string{"A的秘密", "共享"}, nil, 1, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if hasName(resB.Entities, "A的秘密") {
|
||
t.Fatalf("B 不该看到 A 的 temp:%v", names(resB.Entities))
|
||
}
|
||
if !hasName(resB.Entities, "共享") {
|
||
t.Fatalf("B 应看得到共享的 main:%v", names(resB.Entities))
|
||
}
|
||
}
|
||
|
||
// allowTempGraphWrite=false:子对图记忆完全只读(没有 temp 实例,写入被拒)。
|
||
func TestLightMemory_WriteDisabledIsFullyReadOnly(t *testing.T) {
|
||
dir := t.TempDir()
|
||
main := newMainWith(t, dir, Triple{Subject: "张三", Relation: "任职于", Object: "甲公司"})
|
||
defer main.Close()
|
||
|
||
light, err := NewLightMemory(main, "", false)
|
||
if err != nil {
|
||
t.Fatalf("不允许写时不应要求 temp 路径: %v", err)
|
||
}
|
||
defer light.Close()
|
||
|
||
if light.AllowWrite() {
|
||
t.Fatal("AllowWrite 应为 false")
|
||
}
|
||
if light.Temp() != nil {
|
||
t.Fatal("不允许写时不该有 temp 实例")
|
||
}
|
||
if _, _, err := light.Commit([]Triple{{Subject: "李四", Relation: "x", Object: "y"}}, "s", 1); err == nil {
|
||
t.Fatal("不允许写时的写入必须被拒")
|
||
}
|
||
// 读仍然可用(只读主库)。
|
||
res, err := light.Recall([]string{"张三"}, nil, 1, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !hasName(res.Entities, "张三") {
|
||
t.Fatalf("只读模式仍应读到主库:%v", names(res.Entities))
|
||
}
|
||
}
|
||
|
||
// 并集必须去重:同一个实体同时存在于 main 与 temp 时只出现一次。
|
||
func TestLightMemory_UnionDedupesSameEntity(t *testing.T) {
|
||
dir := t.TempDir()
|
||
main := newMainWith(t, dir, Triple{Subject: "张三", Relation: "任职于", Object: "甲公司"})
|
||
defer main.Close()
|
||
|
||
light, err := NewLightMemory(main, filepath.Join(dir, "sub.db"), true)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer light.Close()
|
||
if _, _, err := light.Commit([]Triple{{Subject: "张三", Relation: "擅长", Object: "Go"}}, "s", 1); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
res, err := light.Recall([]string{"张三"}, nil, 1, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
n := 0
|
||
for _, e := range res.Entities {
|
||
if e.Name == "张三" {
|
||
n++
|
||
}
|
||
}
|
||
if n != 1 {
|
||
t.Fatalf("同名实体在并集中应只出现一次,实际 %d 次:%v", n, names(res.Entities))
|
||
}
|
||
}
|
||
|
||
// 并集查询的确定性(同样的输入必得同样的顺序),便于断言与展示稳定。
|
||
func TestMergeRecall_Deterministic(t *testing.T) {
|
||
a := &RecallResult{
|
||
Entities: []Entity{{Name: "b", MentionCount: 1}, {Name: "a", MentionCount: 1}},
|
||
Relations: []Relation{
|
||
{SourceName: "z", RelationType: "r", TargetName: "y"},
|
||
{SourceName: "a", RelationType: "r", TargetName: "b"},
|
||
},
|
||
}
|
||
b := &RecallResult{
|
||
Entities: []Entity{{Name: "a", MentionCount: 5}},
|
||
Relations: []Relation{{SourceName: "a", RelationType: "r", TargetName: "b"}},
|
||
}
|
||
got := mergeRecall(a, b)
|
||
if len(got.Entities) != 2 || got.Entities[0].Name != "a" || got.Entities[1].Name != "b" {
|
||
t.Fatalf("实体合并结果=%v", names(got.Entities))
|
||
}
|
||
if got.Entities[0].MentionCount != 5 {
|
||
t.Fatalf("同名实体应保留 mention_count 较大者,实际 %d", got.Entities[0].MentionCount)
|
||
}
|
||
if len(got.Relations) != 2 {
|
||
t.Fatalf("关系应去重后剩 2 条,实际 %d", len(got.Relations))
|
||
}
|
||
if got.Relations[0].SourceName != "a" {
|
||
t.Fatalf("关系未按源名排序:%+v", got.Relations)
|
||
}
|
||
}
|