From ad60e10e957fd347ea182f0b9e2d3dadfacfef67 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 3 Aug 2026 22:36:54 +0400 Subject: [PATCH] mavend: run the fact vector repair on start, and test what it does (V-493) Automatic rather than a flag, unlike -reembed: only voice-tapped facts are in this index, so it is tens of embeddings rather than thousands of notes. And waiting for an operator to know the repair exists is the failure being fixed. Co-Authored-By: Claude Opus 5 --- cmd/mavend/voicewire.go | 31 ++++++ internal/store/factvectors_test.go | 151 +++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 internal/store/factvectors_test.go 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) + } +}