Merge branch 'fix/g09' into fix/integrated

This commit is contained in:
kami
2026-08-01 14:22:24 +04:00
31 changed files with 1431 additions and 159 deletions
+25 -1
View File
@@ -7,6 +7,20 @@ import (
"sync"
)
// NonRecallPrefix — rows whose id starts with this are excluded from Search by
// every backend. Speaker voiceprints live in the same vector table as notes and
// facts (internal/speaker writes them under this prefix), and they are not
// recall material: a voiceprint has no text to read back and surfacing one as a
// note hit leaks a name attached to a biometric.
//
// Reading them through Catalog.ByPrefix was documented as what keeps them out
// of recall. It is not. It controls how speaker code reads its own rows and
// says nothing about what Search scores. What actually kept them out was that
// cosine returns 0 on a width mismatch, so a 192-dim voiceprint scored 0
// against a 384-dim query. That is a coincidence of two model choices — some
// x-vector exports are 384-dim — and not an invariant. This is the invariant.
const NonRecallPrefix = "speaker:"
// Result is a single search hit.
type Result struct {
ID string
@@ -93,7 +107,14 @@ func (s *InMemoryStore) ByPrefix(_ context.Context, prefix string) ([]Record, er
if !strings.HasPrefix(it.id, prefix) {
continue
}
out = append(out, Record{ID: it.id, Vec: append([]float32(nil), it.vec...), Meta: it.meta})
// Copy the metadata too. Returning it.meta by reference let a caller
// mutating the returned map edit the stored row, and the persistent
// backend unmarshals fresh, so the two disagreed.
meta := make(map[string]string, len(it.meta))
for k, v := range it.meta {
meta[k] = v
}
out = append(out, Record{ID: it.id, Vec: append([]float32(nil), it.vec...), Meta: meta})
}
return out, nil
}
@@ -127,6 +148,9 @@ func (s *InMemoryStore) Search(_ context.Context, vec []float32, topK int) ([]Re
scores := make([]scored, 0, len(s.items))
for _, it := range s.items {
if strings.HasPrefix(it.id, NonRecallPrefix) {
continue
}
score := cosine(vec, it.vec)
scores = append(scores, scored{id: it.id, score: score, meta: it.meta})
}
+72
View File
@@ -80,3 +80,75 @@ func TestCosineEdgeCases(t *testing.T) {
t.Errorf("dot(1,2;1,2) = %f, want 5", c)
}
}
// The in-memory backend has to hide voiceprints from recall exactly like the
// persistent one, or a test passing here says nothing about the daemon.
func TestInMemorySearchSkipsVoiceprints(t *testing.T) {
ctx := context.Background()
s := NewInMemoryStore()
if err := s.Insert(ctx, "note:1", []float32{0, 1}, map[string]string{"text": "заметка"}); err != nil {
t.Fatal(err)
}
if err := s.Insert(ctx, NonRecallPrefix+"kami", []float32{1, 0}, map[string]string{"name": "Ками"}); err != nil {
t.Fatal(err)
}
got, err := s.Search(ctx, []float32{1, 0}, 10)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].ID != "note:1" {
t.Fatalf("Search = %+v, want just the note", got)
}
recs, err := s.ByPrefix(ctx, NonRecallPrefix)
if err != nil || len(recs) != 1 {
t.Fatalf("ByPrefix = %+v, %v; want the voiceprint", recs, err)
}
}
// ByPrefix hands back a copy of the metadata. It used to return the stored map
// by reference, so a caller editing a returned Record silently edited the row,
// and the persistent backend did not behave that way.
func TestInMemoryByPrefixCopiesMeta(t *testing.T) {
ctx := context.Background()
s := NewInMemoryStore()
if err := s.Insert(ctx, "speaker:kami", []float32{1, 0}, map[string]string{"name": "Ками"}); err != nil {
t.Fatal(err)
}
recs, err := s.ByPrefix(ctx, "speaker:")
if err != nil || len(recs) != 1 {
t.Fatalf("ByPrefix = %+v, %v", recs, err)
}
recs[0].Meta["name"] = "не Ками"
again, err := s.ByPrefix(ctx, "speaker:")
if err != nil {
t.Fatal(err)
}
if again[0].Meta["name"] != "Ками" {
t.Errorf("the stored row was edited through the returned map: %q", again[0].Meta["name"])
}
}
// Insert upserts. This is not only a speaker-profile concern: every user of the
// in-memory store used to accumulate a second row for a re-indexed id, and the
// stale copy stayed searchable.
func TestInMemoryInsertUpserts(t *testing.T) {
ctx := context.Background()
s := NewInMemoryStore()
if err := s.Insert(ctx, "note:1", []float32{1, 0}, map[string]string{"text": "старое"}); err != nil {
t.Fatal(err)
}
if err := s.Insert(ctx, "note:1", []float32{0, 1}, map[string]string{"text": "новое"}); err != nil {
t.Fatal(err)
}
got, err := s.Search(ctx, []float32{1, 0}, 10)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("Search returned %d rows, want 1 (the old copy is still searchable)", len(got))
}
if got[0].Meta["text"] != "новое" {
t.Errorf("row = %q, want the replacement", got[0].Meta["text"])
}
}