Files
Maven/internal/memory/store_test.go
T
kami 7c7bd8ceeb Ship voice enrolment, and report recognition as blocked (#255)
Maven can now be told who someone is. She cannot yet tell who is speaking,
and this commit is careful to say so rather than pretend otherwise.

What works: profiles are enrolled from several deliberately recorded samples,
listed, and deleted. They live in the existing memory_vectors table under a
"speaker:" id prefix, so there is no migration; what that needed was a wider
interface than memory.Store, hence memory.Catalog with ByPrefix and Delete.
Delete is the load-bearing half — a voiceprint someone asked to be rid of has
to actually go, and a search-only store cannot do that. InMemoryStore.Insert
became an upsert by id to match what the persistent store already did.

What does not work, and why it is not faked: there is no speaker-embedding
model on this box. Sixteen ggufs in /mnt/hdd1/llms, all text; no ECAPA, no
x-vector, no titanet, no wespeaker, no .onnx anywhere under /mnt/hdd1. So
newSpeakerEmbedder returns nil, internal/speaker falls back to
speaker.Disabled, Identify answers ErrDisabled, and the daemon logs which
half is off at startup. The plan's "simple MFCC + GMM" floor is refused in
the package comment: MFCC cosine distance detects channel and loudness as
much as voice, and a biometric that is confidently wrong writes false claims
about named people into his memory. A bad floor is worse than none here.

Refused as well, and the reason is in enroll.go's doc comment: the plan asked
for unknown speakers to be enrolled on first interaction with a TTS "кто
это?". There is no request shape in the protocol that could express that.
Taking a biometric of whoever walks past the microphone does it to guests who
are not party to the exchange, and a synthesised question into a room is not
consent from whoever answers.

Authority: enrolment is AuthStepUp, because it is a deliberate sit-down act
that writes a biometric of a named person and never something done by voice
mid-conversation. Deletion is one rung lower at AuthWrite, deliberately
inverting the usual pattern — getting rid of a biometric must never be the
harder half. Listing is AuthRead and never returns the vectors themselves.

Off unless configured: no speaker block means the three methods answer
ErrUnknownMethod, so a default box has no wire path that takes a voiceprint.

make build and make test pass.

Vikunja #255

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 05:23:03 +04:00

83 lines
2.1 KiB
Go

package memory
import (
"context"
"fmt"
"math"
"testing"
)
func TestInsertAndSearch(t *testing.T) {
s := NewInMemoryStore()
ctx := context.Background()
// Insert a few vectors
s.Insert(ctx, "doc1", []float32{1, 0, 0}, nil)
s.Insert(ctx, "doc2", []float32{0, 1, 0}, nil)
s.Insert(ctx, "doc3", []float32{0, 0, 1}, map[string]string{"source": "note"})
// Search for something close to doc1
results, err := s.Search(ctx, []float32{0.9, 0.1, 0}, 5)
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(results) != 3 {
t.Fatalf("expected 3 results, got %d", len(results))
}
if results[0].ID != "doc1" {
t.Errorf("nearest should be doc1, got %s", results[0].ID)
}
if math.Abs(results[0].Score-0.9) > 0.01 {
t.Errorf("doc1 score should be near 0.9, got %f", results[0].Score)
}
// Check metadata preserved
if results[2].Meta["source"] != "note" {
t.Errorf("doc3 meta.source = %q, want note", results[2].Meta["source"])
}
}
func TestTopKTruncation(t *testing.T) {
s := NewInMemoryStore()
ctx := context.Background()
// Distinct ids: Insert upserts by id, so ten rows need ten ids.
for i := 0; i < 10; i++ {
s.Insert(ctx, fmt.Sprintf("n%d", i), []float32{float32(i) / 10, 0, 0}, nil)
}
results, err := s.Search(ctx, []float32{1, 0, 0}, 3)
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(results) != 3 {
t.Fatalf("expected 3 results with topK=3, got %d", len(results))
}
}
func TestEmptyStore(t *testing.T) {
s := NewInMemoryStore()
results, err := s.Search(context.Background(), []float32{1, 0, 0}, 5)
if err != nil {
t.Fatalf("Search on empty store: %v", err)
}
if len(results) != 0 {
t.Fatalf("expected 0 results, got %d", len(results))
}
}
func TestCosineEdgeCases(t *testing.T) {
if c := cosine(nil, []float32{1}); c != 0 {
t.Errorf("nil first: expected 0, got %f", c)
}
if c := cosine([]float32{1}, nil); c != 0 {
t.Errorf("nil second: expected 0, got %f", c)
}
if c := cosine([]float32{}, []float32{}); c != 0 {
t.Errorf("empty: expected 0, got %f", c)
}
if c := cosine([]float32{1, 2}, []float32{1, 2}); math.Abs(c-5) > 0.001 {
t.Errorf("dot(1,2;1,2) = %f, want 5", c)
}
}