Files
HomeAgent/internal/memory/reclaim_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

118 lines
3.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
// N2d 数据面:**回收**=父读子的 temp → 选记录 → 合入 main。
//
// 设计 §9回收是"取消语义"(收割后取消该驻留子),且**由父决定纳入哪些**。
// 这里只做数据面(读得到、合得进、幂等),控制面(谁来决定、何时取消)在 N3/N6。
import (
"path/filepath"
"testing"
)
func TestExportTriples_OnlyActiveWithNames(t *testing.T) {
dir := t.TempDir()
g, err := NewGraphDB(filepath.Join(dir, "g.db"))
if err != nil {
t.Fatal(err)
}
defer g.Close()
if _, _, err := g.Commit([]Triple{
{Subject: "张三", Relation: "任职于", Object: "甲公司"},
{Subject: "李四", Relation: "合作", Object: "王五"},
}, "sess", 1); err != nil {
t.Fatal(err)
}
got, err := g.ExportTriples(0)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("导出 %d 条,期望 2%+v", len(got), got)
}
// 实体名必须带出来(合入侧要靠名字 upsert而不是内部 id
seen := map[string]bool{}
for _, tr := range got {
if tr.Subject == "" || tr.Object == "" || tr.Relation == "" {
t.Fatalf("导出的三元组字段不全:%+v", tr)
}
seen[tr.Subject+"→"+tr.Object] = true
}
if !seen["张三→甲公司"] || !seen["李四→王五"] {
t.Fatalf("导出内容不对:%+v", got)
}
// limit 生效。
one, err := g.ExportTriples(1)
if err != nil {
t.Fatal(err)
}
if len(one) != 1 {
t.Fatalf("limit=1 应导出 1 条,实际 %d", len(one))
}
}
// 回收主流程:子写 temp → 父导出 → 选中的合入 main主库拿到、temp 不变。
func TestReclaim_HarvestChildTempIntoMain(t *testing.T) {
dir := t.TempDir()
main, err := NewGraphDB(filepath.Join(dir, "main.db"))
if err != nil {
t.Fatal(err)
}
defer main.Close()
child, err := NewLightMemory(main, filepath.Join(dir, "sub.db"), true)
if err != nil {
t.Fatal(err)
}
defer child.Close()
// 子在 temp 里积累了两条发现。
if _, _, err := child.Commit([]Triple{
{Subject: "子发现A", Relation: "指向", Object: "结论1"},
{Subject: "子发现B", Relation: "指向", Object: "结论2"},
}, "sess", 1); err != nil {
t.Fatal(err)
}
// 父:导出 → **选一条**"哪些纳入记忆"由父决定)→ 写进 main。
all, err := child.Temp().ExportTriples(0)
if err != nil {
t.Fatal(err)
}
if len(all) != 2 {
t.Fatalf("导出 %d 条,期望 2", len(all))
}
selected := []Triple{all[0]}
if _, _, err := main.Commit(selected, "reclaim", 0); err != nil {
t.Fatalf("合入 main 失败: %v", err)
}
// 主库只拿到选中的那条。
mainRes, err := main.Recall([]string{"子发现A", "子发现B"}, nil, 1, "")
if err != nil {
t.Fatal(err)
}
if !hasName(mainRes.Entities, "子发现A") {
t.Fatalf("选中的记录应进主库:%v", names(mainRes.Entities))
}
if hasName(mainRes.Entities, "子发现B") {
t.Fatalf("未选中的记录不该进主库:%v", names(mainRes.Entities))
}
// 重复收割是幂等的Commit 按实体名 upsert + 关系唯一约束)。
before := len(mainRes.Entities)
if _, _, err := main.Commit(selected, "reclaim", 0); err != nil {
t.Fatal(err)
}
after, err := main.Recall([]string{"子发现A"}, nil, 1, "")
if err != nil {
t.Fatal(err)
}
if len(after.Entities) != before {
t.Fatalf("重复收割不应产生重复实体before=%d after=%d", before, len(after.Entities))
}
}