Files
Maven/internal/store/memory_test.go
T
kami f02f3b55b6 memory: keep voiceprints out of note and fact recall
Speaker profiles share the vector table with notes and facts. The doc
comment said reading them through Catalog is what keeps recall from
ranking a voiceprint. It is not. Catalog controls how speaker code reads
its own rows and says nothing about Search, which scanned every row.
What actually hid them was cosine returning 0 on a width mismatch, so a
192-dim ECAPA row scored 0 against a 384-dim query. Some x-vector
exports are 384-dim, and one of those would have surfaced speaker:kami
as a recall hit carrying the name of a person.

Both backends now skip the prefix in Search, and the prefix is one
constant in internal/memory so the store layer can filter on it without
importing internal/speaker.

Two more differences between the backends closed here. ByPrefix on the
in-memory store returned the stored metadata map by reference, so a
caller editing a returned Record edited the row, while the persistent
one unmarshals fresh. And the append to upsert change in Insert is a fix
in its own right, not only a speaker concern: any re-indexed id used to
leave a second stale copy searchable.

Found in review of #74.
2026-08-01 14:12:43 +04:00

142 lines
4.0 KiB
Go

package store
import (
"context"
"path/filepath"
"strings"
"testing"
"github.com/kami/maven/internal/memory"
)
func newMemTestStore(t *testing.T) *Store {
t.Helper()
path := filepath.Join(t.TempDir(), "mem_test.db")
st, err := Open(context.Background(), path)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
return st
}
func TestMemoryStoreInsertSearch(t *testing.T) {
ctx := context.Background()
m := newMemTestStore(t).VectorMemory()
// three orthonormal-ish vectors; a query aligned with the second must rank it top.
if err := m.Insert(ctx, "a", []float32{1, 0, 0}, map[string]string{"text": "вода"}); err != nil {
t.Fatal(err)
}
if err := m.Insert(ctx, "b", []float32{0, 1, 0}, map[string]string{"text": "сон", "type": "fact"}); err != nil {
t.Fatal(err)
}
if err := m.Insert(ctx, "c", []float32{0, 0, 1}, map[string]string{"text": "еда"}); err != nil {
t.Fatal(err)
}
got, err := m.Search(ctx, []float32{0, 1, 0}, 2)
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(got) != 2 {
t.Fatalf("topK=2 returned %d results", len(got))
}
if got[0].ID != "b" {
t.Errorf("top hit = %q, want b", got[0].ID)
}
if got[0].Meta["text"] != "сон" || got[0].Meta["type"] != "fact" {
t.Errorf("meta not round-tripped: %v", got[0].Meta)
}
if got[0].Score < 0.99 {
t.Errorf("aligned vector score = %v, want ~1.0", got[0].Score)
}
}
func TestMemoryStoreUpsertReplaces(t *testing.T) {
ctx := context.Background()
m := newMemTestStore(t).VectorMemory()
if err := m.Insert(ctx, "x", []float32{1, 0}, map[string]string{"text": "старое"}); err != nil {
t.Fatal(err)
}
if err := m.Insert(ctx, "x", []float32{0, 1}, map[string]string{"text": "новое"}); err != nil {
t.Fatal(err)
}
got, err := m.Search(ctx, []float32{0, 1}, 10)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("re-inserting the same id produced %d rows, want 1 (upsert)", len(got))
}
if got[0].Meta["text"] != "новое" {
t.Errorf("upsert kept the old value: %q", got[0].Meta["text"])
}
}
func TestMemoryStorePersistsAcrossReopen(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "persist.db")
st, err := Open(ctx, path)
if err != nil {
t.Fatal(err)
}
if err := st.VectorMemory().Insert(ctx, "k", []float32{1, 0, 0}, map[string]string{"text": "запомни"}); err != nil {
t.Fatal(err)
}
if err := st.Close(); err != nil {
t.Fatal(err)
}
// reopen the same file — the in-memory floor would have lost this.
st2, err := Open(ctx, path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st2.Close() })
got, err := st2.VectorMemory().Search(ctx, []float32{1, 0, 0}, 1)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Meta["text"] != "запомни" {
t.Fatalf("memory did not survive reopen: %v", got)
}
}
// A voiceprint sharing the vector table must never rank as a note hit. The
// dimensions match here on purpose: what used to hide these rows was cosine
// returning 0 on a width mismatch, which is a property of two model choices and
// not of the store.
func TestMemoryStoreSearchSkipsVoiceprints(t *testing.T) {
ctx := context.Background()
m := newMemTestStore(t).VectorMemory()
if err := m.Insert(ctx, "note:1", []float32{0, 1, 0}, map[string]string{"text": "заметка"}); err != nil {
t.Fatal(err)
}
if err := m.Insert(ctx, memory.NonRecallPrefix+"kami", []float32{1, 0, 0}, map[string]string{"name": "Ками"}); err != nil {
t.Fatal(err)
}
got, err := m.Search(ctx, []float32{1, 0, 0}, 10)
if err != nil {
t.Fatalf("Search: %v", err)
}
for _, r := range got {
if strings.HasPrefix(r.ID, memory.NonRecallPrefix) {
t.Fatalf("recall returned a voiceprint: %+v", r)
}
}
if len(got) != 1 || got[0].ID != "note:1" {
t.Fatalf("Search = %+v, want just the note", got)
}
// The row is still there for the speaker code that owns it.
recs, err := m.ByPrefix(ctx, memory.NonRecallPrefix)
if err != nil || len(recs) != 1 {
t.Fatalf("ByPrefix = %+v, %v; want the voiceprint", recs, err)
}
}