diff --git a/internal/store/facts.go b/internal/store/facts.go index ffdfd43..f8bbef7 100644 --- a/internal/store/facts.go +++ b/internal/store/facts.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "strings" "time" @@ -332,6 +333,20 @@ func (s *Store) VoidLatestFact(ctx context.Context, key, source string, ts time. if err != nil { return 0, 0, fmt.Errorf("void: last insert id: %w", err) } + // The other half of the repair (#470). A fact reaches recall through a + // vector keyed `fact::`, holding the utterance that wrote it. + // Voiding the row alone left that vector answering questions, so revert + // reported success on a box that stayed broken. Deleting every vector for + // the key covers the earlier rows too: their values are superseded, and a + // superseded value has no business claiming a turn. + // + // Best-effort by design: the audit trail is already committed, and a fact + // that is voided but still recallable is better than a void that failed. + if n, derr := s.VectorMemory().DeletePrefix(ctx, "fact:"+key+":"); derr != nil { + log.Printf("store: void %q: memory vectors survive: %v", key, derr) + } else if n > 0 { + log.Printf("store: void %q: dropped %d memory vector(s)", key, n) + } return oldID, newID, nil } diff --git a/internal/store/memory.go b/internal/store/memory.go index bc83648..afcfd55 100644 --- a/internal/store/memory.go +++ b/internal/store/memory.go @@ -148,6 +148,27 @@ func (m *MemoryStore) Delete(ctx context.Context, id string) error { return nil } +// DeletePrefix removes every vector whose id starts with prefix and returns +// how many went. Same escaping as ByPrefix, so a key containing % or _ cannot +// widen the delete. +// +// It exists for the repair half of a revert (#470). Voiding a fact row left +// its vector in the index, so recall kept serving the voided fact's utterance +// and the documented repair did not repair. +func (m *MemoryStore) DeletePrefix(ctx context.Context, prefix string) (int64, error) { + pattern := escapeLike(prefix) + "%" + res, err := m.db.ExecContext(ctx, + `DELETE FROM memory_vectors WHERE id LIKE ? ESCAPE '\'`, pattern) + if err != nil { + return 0, fmt.Errorf("memory: delete prefix %q: %w", prefix, err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("memory: delete prefix %q: rows affected: %w", prefix, err) + } + return n, nil +} + // escapeLike neutralises the LIKE wildcards in a literal prefix. func escapeLike(s string) string { r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) diff --git a/internal/store/voidvectors_test.go b/internal/store/voidvectors_test.go new file mode 100644 index 0000000..1e431ae --- /dev/null +++ b/internal/store/voidvectors_test.go @@ -0,0 +1,64 @@ +package store + +import ( + "context" + "database/sql" + "testing" + "time" +) + +// Stage 3 of #470: reverting a fact reported success and left the vector that +// was answering questions, so the documented repair did not repair. +func TestVoidLatestFactDropsMemoryVectors(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) + } + // The id shape actionFact writes: fact::. + if err := mem.Insert(ctx, "fact:go_version:1", []float32{1, 0, 0}, map[string]string{ + "type": "fact", "text": "какая последняя версия языка Go?", + }); err != nil { + t.Fatalf("Insert: %v", err) + } + // A vector for another key must survive the void. + if err := mem.Insert(ctx, "fact:water:1", []float32{0, 1, 0}, map[string]string{ + "type": "fact", "text": "запиши что я пил воду", + }); err != nil { + t.Fatalf("Insert: %v", err) + } + + if _, _, err := s.VoidLatestFact(ctx, "go_version", "feedback", now.Add(time.Minute)); err != nil { + t.Fatalf("VoidLatestFact: %v", err) + } + + got, err := mem.ByPrefix(ctx, "fact:") + if err != nil { + t.Fatalf("ByPrefix: %v", err) + } + if len(got) != 1 || got[0].ID != "fact:water:1" { + t.Fatalf("after the void the index holds %+v; want only fact:water:1", got) + } +} + +func TestDeletePrefixDoesNotWidenOnWildcards(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + mem := s.VectorMemory() + + for _, id := range []string{"fact:a_b:1", "fact:axb:1"} { + if err := mem.Insert(ctx, id, []float32{1, 0}, map[string]string{"type": "fact"}); err != nil { + t.Fatalf("Insert %q: %v", id, err) + } + } + n, err := mem.DeletePrefix(ctx, "fact:a_b:") + if err != nil { + t.Fatalf("DeletePrefix: %v", err) + } + if n != 1 { + t.Fatalf("deleted %d rows; the _ in the key must not match x", n) + } +}