speaker: do not ship three methods that cannot work

The package comment, the embedder log and the startup line all said
enrolment was live and only recognition was blocked. Enroll embeds every
sample before it stores anything, so with no model on the box it fails
on the first sample with ErrDisabled and nothing is ever stored. List
then returns an empty list forever and Forget has nothing to delete. The
shipped state was three methods, all no-ops, announced as a working
half.

SpeakerConfig.Recognizes was written as the gate for this and never
called, so a block with enabled and no model_path wired everything and
skipped the one warning the operator needed. It is the gate now, and
that config shape logs why it stayed off.

Three smaller repairs. ErrDisabled had no case in speakerErr and reached
the surface as an opaque core failure, when it means the same thing
ErrUnknownMethod does. Forget read the row first and answered ErrNotFound
on a second call, so the layer documented as the one that must always
work reintroduced a failure for a voiceprint that was already gone.
And a row with unparsable metadata listed as a plausible profile named
after its own id with 0 samples, which is what a real minimal enrolment
looks like; it is reported as damaged now.

Found in review of #74.
This commit is contained in:
kami
2026-08-01 14:12:43 +04:00
parent f02f3b55b6
commit ec5167de3a
6 changed files with 186 additions and 39 deletions
+52 -2
View File
@@ -269,8 +269,58 @@ func TestForgetRemovesTheVoiceprint(t *testing.T) {
if _, err := r.Get(ctx, "guest"); !errors.Is(err, ErrNotFound) {
t.Errorf("Get after Forget = %v, want ErrNotFound", err)
}
if err := r.Forget(ctx, "guest"); !errors.Is(err, ErrNotFound) {
t.Errorf("second Forget = %v, want ErrNotFound", err)
// Forgetting twice is not an error. A surface retrying after a partial
// failure must not be told the voice it asked to remove is missing.
if err := r.Forget(ctx, "guest"); err != nil {
t.Errorf("second Forget = %v, want nil", err)
}
}
// A row whose metadata is corrupt lists as damaged, not as a plausible profile
// enrolled from zero samples. Those two used to look identical.
func TestCorruptMetadataListsAsDamaged(t *testing.T) {
r, cat := newRec(t, &fakeEmbedder{vec: []float32{1, 0}})
ctx := context.Background()
if err := cat.Insert(ctx, Prefix+"kami", []float32{1, 0}, map[string]string{
"kind": "speaker",
"samples": "12x",
"enrolled": "yesterday",
}); err != nil {
t.Fatal(err)
}
ps, err := r.List(ctx)
if err != nil {
t.Fatal(err)
}
if len(ps) != 1 {
t.Fatalf("List returned %d profiles, want 1", len(ps))
}
p := ps[0]
if !p.Damaged {
t.Errorf("profile %+v is not marked damaged", p)
}
if p.Samples != 0 || !p.Enrolled.IsZero() {
t.Errorf("unreadable metadata was parsed anyway: samples %d, enrolled %v", p.Samples, p.Enrolled)
}
if len(p.Vec) != 2 {
t.Errorf("the voiceprint was dropped: %v", p.Vec)
}
}
// A clean row is not damaged. Without this the flag could be set always and
// the test above would still pass.
func TestCleanProfileIsNotDamaged(t *testing.T) {
r, _ := newRec(t, &fakeEmbedder{vec: []float32{1, 0}})
ctx := context.Background()
if _, err := r.Enroll(ctx, "kami", "Ками", enrolSamples(3, 4)); err != nil {
t.Fatal(err)
}
ps, err := r.List(ctx)
if err != nil {
t.Fatal(err)
}
if len(ps) != 1 || ps[0].Damaged {
t.Fatalf("List = %+v, want one undamaged profile", ps)
}
}