diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index 70cf136..f4fd334 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -146,6 +146,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem emb = router.NewHashEmbedder(1024) } w.embedder = emb + repairFactVectors(dataStore, emb) checkStoredEmbedder(dataStore, emb) // ----- tool executor (the enabled act allowlist, store-backed) ----- @@ -473,6 +474,36 @@ func seedTools(api ipc.CoreAPI, tools []config.ToolConfig) { log.Printf("voice: seeded %d act tools from config", n) } +// repairFactVectors brings stored fact vectors in line with the facts they name +// (#493), once per box, before the embedder marker is even looked at. +// +// Automatic and not a flag, unlike -reembed: only voice-tapped facts are in +// this index, so the work is tens of embeddings rather than the thousands of +// notes that made the backfill a deliberate act. And the box that needs it is +// broken in a way nobody can see — recall answers with the wrong text and +// nothing logs an error — so waiting for an operator to know to run it is how +// the defect survived four restarts in the first place. +func repairFactVectors(dataStore *store.Store, emb router.Embedder) { + if dataStore == nil { + return + } + res, err := dataStore.RepairFactVectors(context.Background(), + // EmbedPassage, the stored side, same as every other writer of these + // vectors. + func(ctx context.Context, text string) ([]float32, error) { + return router.EmbedPassage(ctx, emb, text) + }) + if err != nil { + log.Printf("voice: fact vector repair failed, no marker written and nothing half-done — retried next start: %v", err) + return + } + if res.Skipped || res.Rewritten+res.Dropped == 0 { + return + } + log.Printf("voice: fact vector repair — %d re-embedded from the fact they name, %d dropped as voided or superseded, %d already right, took %s (#493)", + res.Rewritten, res.Dropped, res.Kept, res.Took.Round(time.Millisecond)) +} + // reembedOnStart is the -reembed flag (set in run()). Opt-in on purpose: see // runReembed. var reembedOnStart bool diff --git a/internal/store/factvectors_test.go b/internal/store/factvectors_test.go new file mode 100644 index 0000000..cb3f662 --- /dev/null +++ b/internal/store/factvectors_test.go @@ -0,0 +1,151 @@ +package store + +import ( + "context" + "database/sql" + "testing" + "time" +) + +// The write-path half of #493: recall of a fact must read back the fact, not +// the sentence he happened to say. +func TestFactRecallText(t *testing.T) { + for _, tc := range []struct { + name, key, value, want string + }{ + {"json value", "go_version", `"1.20"`, "go version — 1.20"}, + {"plain value", "water", "выпил", "water — выпил"}, + {"no value", "shower", "", "shower"}, + {"underscores are spoken as spaces", "espresso_machine", `"чистая"`, "espresso machine — чистая"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := FactRecallText(tc.key, tc.value); got != tc.want { + t.Fatalf("FactRecallText(%q, %q) = %q; want %q", tc.key, tc.value, got, tc.want) + } + }) + } +} + +// A correction left the superseded value in the index, so recall answered with +// the value he had just corrected (#493). +func TestCorrectValueDropsMemoryVectors(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + now := time.Now() + mem := s.VectorMemory() + + if _, err := s.WriteFact(ctx, now, KindSelf, "go_version", `"1.20"`, "tap:voice", 1.0, sql.NullInt64{}); err != nil { + t.Fatalf("WriteFact: %v", err) + } + if err := mem.Insert(ctx, "fact:go_version:1", []float32{1, 0, 0}, map[string]string{ + "type": "fact", "text": "go version — 1.20", + }); err != nil { + t.Fatalf("Insert: %v", err) + } + if _, err := s.CorrectValue(ctx, "go_version", "feedback", "1.25", now.Add(time.Minute)); err != nil { + t.Fatalf("CorrectValue: %v", err) + } + got, err := mem.ByPrefix(ctx, "fact:") + if err != nil { + t.Fatalf("ByPrefix: %v", err) + } + if len(got) != 0 { + t.Fatalf("after the correction the index still holds %+v; the superseded value must not answer", got) + } +} + +// The recovery path a poisoned box had none of (#470 point 4, #493): rows +// written before the fix hold utterances, voided junk and superseded values, +// and no write-path change reaches any of them. +func TestRepairFactVectors(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + now := time.Now() + mem := s.VectorMemory() + embed := func(ctx context.Context, text string) ([]float32, error) { + return []float32{float32(len(text)), 1, 0}, nil + } + + // A live fact indexed under the question that wrote it — the defect. + if _, err := s.WriteFact(ctx, now, KindSelf, "water", `"выпил"`, "tap:voice", 1.0, sql.NullInt64{}); err != nil { + t.Fatalf("WriteFact water: %v", err) + } + if err := mem.Insert(ctx, "fact:water:100", []float32{9, 9, 9}, map[string]string{ + "type": "fact", "source": "voice", "text": "запиши что я пил воду", + }); err != nil { + t.Fatalf("Insert water: %v", err) + } + // A voided fact whose vector survived the void. + if _, err := s.WriteFact(ctx, now, KindSelf, "go_version", `"1.20"`, "tap:voice", 1.0, sql.NullInt64{}); err != nil { + t.Fatalf("WriteFact go_version: %v", err) + } + if _, _, err := s.VoidLatestFact(ctx, "go_version", "feedback", now.Add(time.Minute)); err != nil { + t.Fatalf("VoidLatestFact: %v", err) + } + if err := mem.Insert(ctx, "fact:go_version:100", []float32{9, 9, 9}, map[string]string{ + "type": "fact", "text": "какая последняя версия языка Go?", + }); err != nil { + t.Fatalf("Insert go_version: %v", err) + } + // A key with two vectors: only the newest may answer. + if _, err := s.WriteFact(ctx, now, KindSelf, "mood", `"устал"`, "tap:voice", 1.0, sql.NullInt64{}); err != nil { + t.Fatalf("WriteFact mood: %v", err) + } + for _, ts := range []string{"100", "200"} { + if err := mem.Insert(ctx, "fact:mood:"+ts, []float32{9, 9, 9}, map[string]string{ + "type": "fact", "text": "мне грустно", + }); err != nil { + t.Fatalf("Insert mood %s: %v", ts, err) + } + } + // A note must be left entirely alone. + if err := mem.Insert(ctx, "note:7", []float32{5, 5, 5}, map[string]string{ + "type": "note", "text": "сеть тормозит по вечерам", + }); err != nil { + t.Fatalf("Insert note: %v", err) + } + + res, err := s.RepairFactVectors(ctx, embed) + if err != nil { + t.Fatalf("RepairFactVectors: %v", err) + } + if res.Rewritten != 2 || res.Dropped != 2 { + t.Fatalf("repair reported %+v; want 2 rewritten (water, newest mood) and 2 dropped (voided go_version, superseded mood)", res) + } + + got, err := mem.ByPrefix(ctx, "fact:") + if err != nil { + t.Fatalf("ByPrefix: %v", err) + } + texts := map[string]string{} + for _, r := range got { + texts[r.ID] = r.Meta["text"] + } + if len(texts) != 2 { + t.Fatalf("the index holds %+v; want only fact:water:100 and fact:mood:200", texts) + } + if texts["fact:water:100"] != "water — выпил" { + t.Fatalf("water reads back %q; want the fact, not the utterance", texts["fact:water:100"]) + } + if texts["fact:mood:200"] != "mood — устал" { + t.Fatalf("mood reads back %q", texts["fact:mood:200"]) + } + // Provenance the row already carried must survive the rewrite. + for _, r := range got { + if r.ID == "fact:water:100" && r.Meta["source"] != "voice" { + t.Fatalf("water lost its source meta: %+v", r.Meta) + } + } + if notes, err := mem.ByPrefix(ctx, "note:"); err != nil || len(notes) != 1 { + t.Fatalf("the note row was touched: %+v (err %v)", notes, err) + } + + // Marker written, so a second run is free and changes nothing. + again, err := s.RepairFactVectors(ctx, embed) + if err != nil { + t.Fatalf("second RepairFactVectors: %v", err) + } + if !again.Skipped { + t.Fatalf("second run did work: %+v; the marker must make it a no-op", again) + } +}