Files
HomeAgent/internal/memory/light_memory_test.go
JianFeeeee f0562915db feat(memory): N2a 第二块砖 + N2d 数据面 —— 轻量图记忆装配(temp 可写/主库只读/并集)与回收合入
按用户确认的形态:**独立存储实例**(不给共享记忆层加 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。
2026-09-13 09:38:54 +08:00

209 lines
6.3 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
// 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)
}
}