Files
Cyrene_v2/internal/runtime/memory/vector_sync_test.go
T
AskaEth fbad02899a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(memory,loop,capacity): 打通记忆链三条空壳(P0-3 / P0-4 / P0-17,审计 §4.3)
- P0-3 持久向量索引**不再空壳**:Store.Add 落库后真做 Embed+Add(失败只记日志、绝不让记忆写入失败);
  NewRetriever 拿到 WithVectorIndex 时**自动接上写入路径**(无需改组合根);索引无命中 → WARN + **回退内存余弦**;
  新增每进程一次的**回填**(索引确实为空时把已落库条目向量化,分批 64、上限 512)
- P0-4 工作记忆**按会话接线**:回合落定时写入(owner 会话 + 带会话 id 的窗口,LRU 上限 64),
  组装上下文时按会话读取 ⇒ 前导块真的出现;原单窗口 API 语义不变
- P0-17 L4 学习回环**真回写**:新增 capacity.Judge(只跑判定、不施人格覆盖/不发事件/不改状态);
  loop 在回合落定"只写 owner 会话 + 跳过子代理分支",写 ScopeUser(L4 的读域)+ 结构化溯源
- 顺带红线加固:意图记录是主人**原话**的机器可读形态 ⇒ **绝不注入群聊/陌生人块**(design/03 §5)
- 复核:三条各自的包 build/vet/test 绿;「真生效」断言含生产构造路径(vector.New + 真实 SQLite 后端 +
  n-gram embedder)、跑完一轮窗口非空、L4 回写后能唤醒一个自身无强信号的表达;
  **5 条变异反证**(其中两条专门打「受限会话零写入」与「群聊不泄露主人原话」)均已还原
- 未做(登记):Index.Search 无 ID 过滤(客户端按候选集过滤,极端情况走回退);每条记忆一次 Embed
2026-10-04 14:20:22 +08:00

221 lines
8.3 KiB
Go

package memory
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
)
// countingIndex 包一层真实 vector.Index,数出写入侧与检索侧各用了几次:
// 它是「持久向量索引真的被喂了数据 / 真的被查询了」的观测点,
// 而不是替换掉真实后端的桩 —— 命中与得分仍由底层 SQLite 索引算出。
type countingIndex struct {
vector.Index
adds int
searches int
}
func (c *countingIndex) Add(ctx context.Context, id string, v vector.Vector) error {
c.adds++
return c.Index.Add(ctx, id, v)
}
func (c *countingIndex) Search(ctx context.Context, q vector.Vector, topK int) ([]vector.Hit, error) {
c.searches++
return c.Index.Search(ctx, q, topK)
}
// newTestVectorIndex 用**生产的构造路径** vector.New 建一个真实向量索引
// (默认的零额外依赖 SQLite 后端 + n-gram embedder),只是把库路径换成
// 隔离的内存库,使测试与线上装配走同一条代码路径。
func newTestVectorIndex(t *testing.T, dim int) vector.Index {
t.Helper()
idx, err := vector.New(
config.DBConfig{SQLitePath: ":memory:"},
embedding.NewNGram(dim),
testLogger(),
)
if err != nil {
t.Fatalf("vector.New: %v", err)
}
t.Cleanup(func() { _ = idx.Close() })
return idx
}
// TestStore_Add_WritesVectorIndex_写入侧真生效 是审计 P0-3 的主断言:
// 检索器接入索引后,**每条经 Store.Add 落库的记忆都被向量化并写进
// 索引**,因此对索引自身检索能命中该条目、且自相似度接近 1 ——
// 索引不再是恒空的空壳(design/03 §3「真正启用向量检索」)。
func TestStore_Add_WritesVectorIndex_写入侧真生效(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
idx := &countingIndex{Index: newTestVectorIndex(t, 64)}
// 装配顺序与 cmd/cyrene 一致:先写 store,再经 NewRetriever 把索引
// 接到同一个 store 的写入路径。
r := NewRetriever(store, WithEmbedder(embedding.NewNGram(64)), WithVectorIndex(idx))
if r == nil {
t.Fatal("NewRetriever returned nil")
}
stored := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Content: "她喜欢早上喝手冲咖啡",
Scope: ScopePersona, Kind: KindRelationship,
})
if idx.adds == 0 {
t.Fatal("Store.Add did not write any vector: the persistent index is still an empty shell")
}
// 索引里确实有这条向量:用同一内容的向量去检索,必须命中它本身。
qvecs, err := idx.Embed(ctx, []string{stored.Content})
if err != nil {
t.Fatalf("index embed: %v", err)
}
hits, err := idx.Search(ctx, qvecs[0], 5)
if err != nil {
t.Fatalf("index search: %v", err)
}
if len(hits) == 0 {
t.Fatal("index has no vector for the persisted item")
}
if hits[0].ID != stored.ID {
t.Fatalf("top index hit = %q, want the persisted item %q", hits[0].ID, stored.ID)
}
if hits[0].Score < 0.99 {
t.Fatalf("self-similarity = %v, want ~1 (the stored vector is not the item's own)", hits[0].Score)
}
}
// TestRetriever_Semantic_UsesIndexThenFallsBack_语义路径先索引后回退
// 覆盖 P0-3 的第二半:索引答不出候选命中时,语义分回退到内存余弦,
// 而不再因为「装了索引」就交出一片 0 分。
//
// 三路对照都在同一个候选集上做:① 索引命中候选 → 只有索引给分的条目
// 有语义分(索引作答);② 索引只命中候选之外的 id(全局索引里别的
// 角色的向量)→ 内存余弦接手(装上 embedder 时冷门条目仍有正分);
// ③ 同样的空候选命中但没有 embedder → 没有任何语义来源,分值为空。
func TestRetriever_Semantic_UsesIndexThenFallsBack_语义路径先索引后回退(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
// 内容为英文、查询为德语(无共同词元),关键词路径因此贡献 0:
// 语义分若有,只可能来自向量路径。
item := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Content: "she is a morning person",
Scope: ScopePersona, Kind: KindRelationship,
})
const query = "Kaffeegetränk"
items, err := store.ListByPersona(ctx, "persona.a")
if err != nil {
t.Fatalf("ListByPersona: %v", err)
}
// ① 索引认得候选 → 由索引作答。
hitIdx := &countingIndex{Index: &mockVectorIndex{
dim: 4,
searchFn: func(_ context.Context, _ vector.Vector, _ int) ([]vector.Hit, error) {
return []vector.Hit{{ID: item.ID, Score: 0.5}}, nil
},
}}
fromIndex := NewRetriever(store, WithEmbedder(fixedEmbedder{dim: 4}), WithVectorIndex(hitIdx))
got := fromIndex.semanticScores(ctx, items, query)
if len(got) != 1 || got[item.ID] <= 0 {
t.Fatalf("semantic scores from the index = %+v, want exactly the indexed item %q", got, item.ID)
}
if hitIdx.searches == 0 {
t.Fatal("the vector index was never queried: the semantic path is not wired")
}
// ② 索引只命中候选之外的 id → 候选集内无命中 → 回退内存余弦。
foreignIdx := &countingIndex{Index: &mockVectorIndex{
dim: 4,
searchFn: func(_ context.Context, _ vector.Vector, _ int) ([]vector.Hit, error) {
return []vector.Hit{{ID: "someone-else-item", Score: 0.9}}, nil
},
}}
withFallback := NewRetriever(store, WithEmbedder(fixedEmbedder{dim: 4}), WithVectorIndex(foreignIdx))
fallback := withFallback.semanticScores(ctx, items, query)
if fallback[item.ID] <= 0 {
t.Fatalf("semantic scores = %+v, want a positive score for %q from the in-memory cosine fallback",
fallback, item.ID)
}
// ③ 同样的空候选命中,但没有 embedder 可回退 → 没有语义分。
bare := NewRetriever(store, WithVectorIndex(foreignIdx))
if got := bare.semanticScores(ctx, items, query); len(got) != 0 {
t.Fatalf("semantic scores without an embedder = %+v, want empty", got)
}
}
// TestRetriever_BackfillsPersistedItems_索引空时一次性回填 证明既有
// 条目也进入语义路径:写入发生在接入索引**之前**(进程重启后读到的
// 历史记忆就是这种状态),首次语义检索发现索引为空,于是把已落库
// 的条目一次性向量化并写进索引 —— 之后它们真的能被索引检索到。
func TestRetriever_BackfillsPersistedItems_索引空时一次性回填(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
// 索引接入之前写入的两条记忆(模拟上一次运行留下的历史条目)。
first := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Content: "她喜欢早上喝手冲咖啡",
Scope: ScopePersona, Kind: KindRelationship,
})
addItem(t, store, MemoryItem{
PersonaID: "persona.a", Content: "她周末去山里徒步",
Scope: ScopePersona, Kind: KindRelationship,
})
idx := &countingIndex{Index: newTestVectorIndex(t, 64)}
r := NewRetriever(store, WithEmbedder(embedding.NewNGram(64)), WithVectorIndex(idx))
if _, err := r.Retrieve(ctx, "persona.a", "手冲咖啡", 10); err != nil {
t.Fatalf("Retrieve: %v", err)
}
if idx.adds < 2 {
t.Fatalf("backfill called Add %d times, want >= 2 (both persisted items)", idx.adds)
}
qvecs, err := idx.Embed(ctx, []string{first.Content})
if err != nil {
t.Fatalf("index embed: %v", err)
}
hits, err := idx.Search(ctx, qvecs[0], 5)
if err != nil {
t.Fatalf("index search: %v", err)
}
if len(hits) == 0 || hits[0].ID != first.ID {
t.Fatalf("index hits = %+v, want the backfilled item %q first", hits, first.ID)
}
// 回填只做一次:再检索一轮不再新增 Add。
before := idx.adds
if _, err := r.Retrieve(ctx, "persona.a", "徒步", 10); err != nil {
t.Fatalf("second Retrieve: %v", err)
}
if idx.adds != before {
t.Fatalf("backfill ran again: adds %d -> %d, want no further embedding", before, idx.adds)
}
}
// fixedEmbedder 是「任何文本都得到同一个向量」的确定性 embedder:它使
// 内存余弦路径对任意 query/内容都给出正相似度,从而让测试能判定
// 「语义分是否由内存回退贡献」。
type fixedEmbedder struct{ dim int }
func (f fixedEmbedder) Embed(_ context.Context, texts []string) ([]embedding.Vector, error) {
out := make([]embedding.Vector, len(texts))
for i := range texts {
v := make(embedding.Vector, f.dim)
v[0] = 1
out[i] = v
}
return out, nil
}
func (f fixedEmbedder) Dim() int { return f.dim }