Compare commits

...

11 Commits

Author SHA1 Message Date
kami 453919db20 Merge pull request 'Bug: the memory index stores the raw utterance as a fact's recall text, and nothing ever deletes a fact vector' (#100) from task/493-bug-the-memory-index-stores-the-raw-utte into task/470-bug-a-question-writes-invented-knowledge
Reviewed-on: #100
2026-08-03 20:41:09 +02:00
claude ad60e10e95 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 <noreply@anthropic.com>
2026-08-03 22:36:54 +04:00
claude 1528697287 store: repair fact vectors against the facts they name (V-493)
Every write-path fix leaves the rows already stored wrong, and a box in that
state looks fine: recall answers with the wrong text and nothing logs an error.
That is how the original poison survived four restarts.

RepairFactVectors resolves each fact vector against the fact it names,
re-embeds the ones whose text is stale, and deletes the voided, superseded and
orphaned ones. Marker-guarded and idempotent, so it runs once per box and a run
that dies partway is simply redone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:36:54 +04:00
claude dbdab2d570 store, mavend: a fact is indexed as the fact, not as the utterance (V-493)
queryMemory returns a fact's stored text verbatim, so the text the write path
indexed is what he hears. It was the utterance, which made recall of any
voice-tapped fact answer with the sentence he said: go_version = 1.20 was
indexed as "какая последняя версия языка Go?", and that question came back.

FactRecallText renders the fact instead, and the utterance stays in meta as
provenance. Correcting a value now drops the key's vectors the way voiding one
does, since the superseded value was still answering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:36:34 +04:00
kami b9371dcac6 Merge pull request 'Bug: a question writes invented knowledge into memory as a self fact, and recall then serves it back for unrelated questions' (#99) from task/470-bug-a-question-writes-invented-knowledge into master
Reviewed-on: #99
2026-08-03 20:11:11 +02:00
claude 62c2e92ec0 mavend, recalleval: wire the topic veto into both recall sources (V-470)
queryMemory and queryNotes both gate on score alone, so both needed it. The
eval keeps its own copy of bestRecall — package main is not importable — and a
fixture that measures a weaker gate than the daemon runs flatters it, so the copy
moves in step and its test pins the new rule.

Measured on the held-out recall fixture with the real embedder: 17/32 cases pass
→ 22/32, false recall 1/5 → 0/5, answered after gate 18/27 → 17/27. The one true
recall lost is en-hard-024, an English question against a Russian note, where no
lexical test can help.
2026-08-03 13:51:04 +04:00
claude aec94eb2e8 memory: a world question must name what the memory mentions (V-470)
The score gate cannot separate the right note from an unrelated one: the
held-out fixture puts the right note at 0.791-0.890 and the must-be-silent cases
at 0.795-0.835, so a note about his slow network answered 'почему небо синее?'.

RecallAllowed adds a topic veto, and applies it only to a question that mentions
nothing of his. That restriction is the whole design: demanding a shared word of
every recall silenced four true recalls on the fixture to kill one false one,
because recall exists to find the note whose words he no longer remembers. A
question about his own life keeps the embedder as its only judge.
2026-08-03 13:50:54 +04:00
claude 4dfe106fe3 mavend: a question is never a fact about him (V-470)
IntentFact used to persist whatever the model invented for a question-shaped
utterance, at confidence 1.00, and index it for recall under the question's own
text. Two such rows then claimed seven unrelated world questions and silently
disabled world answering.

A question now goes down the query chain, which is what he asked for. The second
half is confidence: a value grounded in what he said stays 1.00, a value the model
supplied for words he never said drops to 0.60 and says so in the log. Same
reasoning as 'LLM output is not authorization' on the act path.
2026-08-03 13:40:33 +04:00
claude 2e0e2fd0bb router: a deterministic test for question-shaped text (V-470)
The predicate a fact write needs before it trusts a routing decision. Tokenized,
not substring: 'что' inside 'чтобы' is not a question. Capture verbs win over
every question signal, because 'запиши что я пил воду' contains an interrogative
and is still a capture.
2026-08-03 13:40:33 +04:00
claude f3fa6b353a store: voiding a fact drops its memory vectors (V-470)
Revert voided the fact row and left the vector, so recall kept serving the
voided fact's utterance and the documented repair reported success on a box that
stayed broken. There was no way to repair a poisoned box at all.

DeletePrefix covers every vector for the key, earlier rows included: their values
are superseded, and a superseded value has no business claiming a turn. It is
best-effort — the audit trail is already committed, and a fact that is voided but
still recallable beats a void that failed.
2026-08-03 13:40:13 +04:00
kami 6645f64c3e Merge pull request 'Name the gap: world questions through the workstation model, and the four remaining callers' (#98) from task/490-name-the-gap-world-questions-through-the into master
Reviewed-on: #98
2026-08-03 11:19:56 +02:00
17 changed files with 1096 additions and 31 deletions
+50 -14
View File
@@ -7,6 +7,7 @@ import (
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
// actionFact handles router.IntentFact: persist a tapped self-fact, index
@@ -15,14 +16,41 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
if !dec.Slots.HasKey {
return "не разобрала, что записать — попробуй иначе."
}
// A question is never a fact about him (#470). "какая последняя версия
// языка Go?" used to land here, and the value stored was whatever the
// model invented for it, at confidence 1.00, indexed for recall under the
// question's own text. Two such rows then claimed seven unrelated world
// questions through recall and silently disabled world answering.
//
// The routing error itself is not fixed here — the answer is to answer.
// Sending the turn down the query chain is what he asked for anyway, and
// it costs a mis-routed capture nothing: an explicit "запиши ..." is not
// question-shaped, so it never takes this branch.
if router.IsQuestionShaped(dec.Utterance) {
log.Printf("voice: fact write refused, utterance is a question: %q (key %q) — answering as a query",
dec.Utterance, dec.Slots.Key)
q := dec
q.Intent = router.IntentQuery
// The key the model extracted is its guess at what to store, not a
// fact he has. Left in place, queryFactByKey would read it back and
// claim the turn before any real source ran.
q.Slots.Key, q.Slots.HasKey = "", false
q.Slots.Value = ""
return h.actionQuery(ctx, q)
}
now := h.now()
req := ipc.WriteFactReq{
Ts: now,
Kind: "self",
Key: dec.Slots.Key,
Value: dec.Slots.Value,
Source: "tap:voice",
Confidence: 1.0,
Ts: now,
Kind: "self",
Key: dec.Slots.Key,
Value: dec.Slots.Value,
Source: "tap:voice",
// Not 1.00 unconditionally any more (#470). A value he said is
// evidence; a value the model supplied for words he never said is a
// guess, and writing a guess at full confidence is the same mistake
// the act path already refuses under "LLM output is not
// authorization".
Confidence: factConfidence(dec.Utterance, dec.Slots.Value),
// Subject: the key doubles as the entity-resolution candidate —
// a voice-tapped fact's key is usually the thing/person it's
// about ("espresso_machine", "kate"), so queueing it for Nexus
@@ -36,17 +64,25 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
log.Printf("voice: write fact: %v", err)
return "не получилось сохранить факт."
}
// Index the fact utterance in long-term memory (best-effort, must not
// fail the fact write). Facts aren't in the notes table, so this is the
// only recall path for them — "когда я пил воду?" reads back from here.
// Index the fact in long-term memory (best-effort, must not fail the fact
// write). Facts aren't in the notes table, so this is the only recall path
// for them — "когда я пил воду?" reads back from here.
//
// The indexed text is the fact, not the utterance (#493). queryMemory
// returns a fact's stored text verbatim, so what goes in here is what he
// hears; storing the utterance meant recall answered with his own sentence
// rather than the value. The utterance stays alongside as provenance —
// readable on /trace, never the answer and never embedded.
if h.memStore != nil {
if vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance); err != nil {
text := store.FactRecallText(dec.Slots.Key, dec.Slots.Value)
if vec, err := router.EmbedPassage(ctx, h.embedder, text); err != nil {
log.Printf("voice: embed fact for memory: %v", err)
} else if err := h.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{
"source": "voice",
"type": "fact",
"text": dec.Utterance,
"ts": strconv.FormatInt(now.Unix(), 10),
"source": "voice",
"type": "fact",
"text": text,
"utterance": dec.Utterance,
"ts": strconv.FormatInt(now.Unix(), 10),
}); err != nil {
log.Printf("voice: memory insert fact: %v", err)
}
+14
View File
@@ -434,6 +434,14 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string
return "", false
}
text := hit.Meta["text"]
// The score cleared the gate and the topic still has to match (#470). A
// note about his slow network scored high enough to answer "почему небо
// синее?", because the right-note and must-be-silent score ranges overlap
// and no threshold sits between them.
if !memory.RecallAllowed(t.dec.Utterance, text) {
log.Printf("voice: recall %q rejected for %q: a world question and no shared topic word", text, t.dec.Utterance)
return "", false
}
// A note is phrased in Maven's voice; a fact is read back as it was
// stored.
if hit.Meta["type"] == "note" {
@@ -469,6 +477,12 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
if !memory.ConfidentScores(noteScores, h.queryMinScore, h.queryMinMargin) {
return "", false
}
// Same topic veto as queryMemory above: the best note must be about what
// he asked, not merely the nearest vector in the index.
if !memory.RecallAllowed(t.dec.Utterance, notes[0].Text) {
log.Printf("voice: note %q rejected for %q: a world question and no shared topic word", notes[0].Text, t.dec.Utterance)
return "", false
}
texts := make([]string, len(notes))
for i, n := range notes {
texts[i] = n.Text
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"log"
"strings"
"unicode"
)
// ungroundedConfidence — what a self fact is worth when its value appears
// nowhere in what he said. Below `query_min_score` is not the point (recall
// gates on vector distance, not on this number); the point is that
// `/history` and every future reader can tell a value he said from a value
// the model supplied.
const ungroundedConfidence = 0.6
// factConfidence scores a self fact by whether its value is grounded in the
// utterance it came from. Grounded stays 1.00, which is what a tapped fact
// has always been worth. Ungrounded drops, and says so in the log.
//
// An empty value is grounded by definition: the key alone carries the fact
// ("поужинал"), and there is nothing for the model to have invented.
func factConfidence(utterance, value string) float64 {
if strings.TrimSpace(value) == "" {
return 1.0
}
if valueGrounded(utterance, value) {
return 1.0
}
log.Printf("voice: fact value %q is not in %q — writing at confidence %.2f",
value, utterance, ungroundedConfidence)
return ungroundedConfidence
}
// valueGrounded reports whether every word of value traces back to a word he
// actually said. The comparison is on a 4-rune prefix, so the model's
// normalization survives ("пил воду" → "вода") while an invented value
// ("1.20" for a question about Go) does not.
func valueGrounded(utterance, value string) bool {
said := factTokens(utterance)
words := factTokens(value)
if len(words) == 0 {
return true
}
for _, w := range words {
if !anyTokenMatches(said, w) {
return false
}
}
return true
}
func anyTokenMatches(said []string, w string) bool {
for _, s := range said {
if s == w || sameStem(s, w) {
return true
}
}
return false
}
// sameStem is inflection tolerance and nothing more: it compares all but the
// last rune of the shorter word, and never fewer than three. Russian marks
// case on the ending, so "пил воду" and the stored "вода" are the same word he
// said, while "1.20" and "версия" are not. A word of three runes or fewer must
// match outright, where a shorter prefix would match half the language.
func sameStem(a, b string) bool {
ar, br := []rune(a), []rune(b)
shorter := min(len(ar), len(br))
n := shorter - 1
if n < 3 || len(ar) < n || len(br) < n {
return false
}
return string(ar[:n]) == string(br[:n])
}
// factTokens lowercases and splits on everything that is not a letter or a
// digit, the same shape planTokens uses in the router.
func factTokens(s string) []string {
return strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
}
+125
View File
@@ -0,0 +1,125 @@
package main
import (
"context"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/tool"
"github.com/kami/maven/internal/voice"
)
func newFactGateHandler(t *testing.T, now time.Time) (*reactiveHandler, ipc.CoreAPI) {
t.Helper()
st := newTestStore(t)
api := ipc.NewStoreAPI(st)
emb := router.NewHashEmbedder(1024)
h := &reactiveHandler{
api: api,
embedder: emb,
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
replier: voice.NewStubReplier(),
now: func() time.Time { return now },
memStore: memory.NewInMemoryStore(),
dataStore: st,
}
return h, api
}
// The write half of #470: a question routed to IntentFact must not become a
// fact about him, and must not leave a vector behind for recall to serve.
func TestActionFact_QuestionIsNotWritten(t *testing.T) {
ctx := context.Background()
h, api := newFactGateHandler(t, time.Now())
reply := h.actionFact(ctx, router.Decision{
Intent: router.IntentFact,
Utterance: "какая последняя версия языка Go?",
Slots: router.Slots{Key: "go_version", HasKey: true, Value: `"1.20"`},
})
if _, err := api.LatestFact(ctx, "go_version"); err == nil {
t.Fatal("a question was stored as a fact about him")
}
hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "какая последняя версия языка Go?"), 3)
if err != nil {
t.Fatalf("memory search: %v", err)
}
if len(hits) != 0 {
t.Fatalf("the question was indexed for recall: %+v", hits)
}
// It went down the query chain instead. Nothing is configured to answer a
// world question in this harness, so "не знаю." is the honest outcome —
// what matters is that the turn was answered, not stored.
if reply == "" {
t.Fatal("the turn was neither stored nor answered")
}
}
// The capture that must survive the gate: an explicit instruction to record,
// even though it contains an interrogative.
func TestActionFact_ExplicitCaptureStillWrites(t *testing.T) {
ctx := context.Background()
h, api := newFactGateHandler(t, time.Now())
h.actionFact(ctx, router.Decision{
Intent: router.IntentFact,
Utterance: "запиши что я пил воду",
Slots: router.Slots{Key: "water", HasKey: true, Value: `"вода"`},
})
f, err := api.LatestFact(ctx, "water")
if err != nil {
t.Fatalf("an explicit capture was refused: %v", err)
}
if f.Confidence != 1.0 {
t.Errorf("confidence = %v, want 1.0 for a value he said", f.Confidence)
}
// #493: what recall reads back is the fact, not the sentence he said.
// queryMemory returns a fact's text verbatim, so the utterance sitting here
// meant "запиши что я пил воду" was the answer to "когда я пил воду?".
hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 3)
if err != nil {
t.Fatalf("memory search: %v", err)
}
if len(hits) != 1 {
t.Fatalf("the fact was not indexed once: %+v", hits)
}
if got := hits[0].Meta["text"]; got != "water — вода" {
t.Errorf("indexed text = %q, want the fact", got)
}
if got := hits[0].Meta["utterance"]; got != "запиши что я пил воду" {
t.Errorf("utterance provenance = %q, want it kept alongside", got)
}
}
func TestFactConfidence(t *testing.T) {
cases := []struct {
utterance, value string
want float64
}{
{"запиши что я пил воду", `"вода"`, 1.0},
{"я выпил кофе", `"кофе"`, 1.0},
{"поужинал", "", 1.0},
{"отметь что я полил кактус", `"полил кактус"`, 1.0},
{"какая последняя версия языка Go", `"1.20"`, ungroundedConfidence},
{"кто премьер Японии", `"Тонио Озаки"`, ungroundedConfidence},
}
for _, c := range cases {
if got := factConfidence(c.utterance, c.value); got != c.want {
t.Errorf("factConfidence(%q, %q) = %v, want %v", c.utterance, c.value, got, c.want)
}
}
}
func mustEmbedPassage(t *testing.T, h *reactiveHandler, text string) []float32 {
t.Helper()
vec, err := router.EmbedQuery(context.Background(), h.embedder, text)
if err != nil {
t.Fatalf("embed %q: %v", text, err)
}
return vec
}
+31
View File
@@ -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
+15 -6
View File
@@ -498,12 +498,21 @@ rejects `https://api.openai.com`, and forget really deletes
(`internal/store/memory.go:145` is a real `DELETE`, not a tombstone). Vision is
19/19, speaker 22/22, media 16/16.
**470 got worse.** Both poisoned facts show `voided` on `/history`, and the
defect survives. Re-measured at 15:42, after four restarts: `почему небо синее?`
still answers `какая последняя версия языка Go?` with no `search:` line. What
comes back is the question he typed, not the value the fact held. So the poison
is a vector in the memory index, and `revert` does not remove it. There is
currently no documented way to repair a poisoned box.
**470 got worse, then closed.** Both poisoned facts showed `voided` on
`/history` and the defect survived. Re-measured at 15:42, after four restarts:
`почему небо синее?` still answered `какая последняя версия языка Go?` with no
`search:` line. What came back was the question he typed, not the value the fact
held. So the poison was a vector in the memory index, and `revert` did not
remove it.
Repaired in two parts. 470 stopped the writes: a question is never a fact, and a
void drops the key's vectors. 493 fixed what the index holds. A fact is indexed
as the fact and not as the utterance, and a correction drops its superseded
vector too.
A poisoned box now repairs itself on the next start. `RepairFactVectors`
re-embeds every fact vector from the fact it names, and deletes the voided and
superseded ones. It runs once, guarded by a marker, and logs what it did.
---
+126
View File
@@ -0,0 +1,126 @@
package memory
import (
"strings"
"unicode"
)
// stopwords — words that carry no topic. A question and a note that share only
// these share nothing: "почему небо синее" and "сеть какая-то медленная" both
// contain "какая"-shaped filler and are about different worlds.
var stopwords = map[string]bool{
// interrogatives and demonstratives
"что": true, "чего": true, "какой": true, "какая": true, "какое": true,
"какие": true, "каких": true, "кто": true, "кого": true, "кому": true,
"почему": true, "зачем": true, "где": true, "куда": true, "откуда": true,
"когда": true, "сколько": true, "как": true, "то": true, "это": true,
"этот": true, "тот": true, "там": true, "тут": true, "такой": true,
// pronouns — every sentence he says is about him, so "я" is not a topic
"я": true, "меня": true, "мне": true, "мой": true, "моя": true, "мои": true,
"ты": true, "тебя": true, "тебе": true, "твой": true, "он": true, "она": true,
"они": true, "мы": true, "себя": true, "свой": true,
// prepositions, conjunctions, particles, copulas
"в": true, "во": true, "на": true, "с": true, "со": true, "у": true,
"о": true, "об": true, "про": true, "за": true, "из": true, "по": true,
"до": true, "от": true, "для": true, "над": true, "под": true, "при": true,
"и": true, "а": true, "но": true, "или": true, "же": true, "ли": true,
"не": true, "ни": true, "бы": true, "был": true, "была": true, "было": true,
"быть": true, "есть": true, "был-ли": true, "уже": true, "ещё": true,
"еще": true, "так": true, "вот": true, "там-же": true,
// English filler, for the mixed utterances he does say
"the": true, "a": true, "an": true, "is": true, "are": true, "was": true,
"were": true, "be": true, "of": true, "in": true, "on": true, "at": true,
"to": true, "for": true, "about": true, "and": true, "or": true, "not": true,
"what": true, "who": true, "why": true, "when": true, "where": true,
"which": true, "how": true, "i": true, "my": true, "me": true, "it": true,
"this": true, "that": true,
}
// firstPerson — the words that make an utterance a question about his own
// life. Not possession only: "как я восстановил конфиги" owns nothing and is
// still about him.
var firstPerson = map[string]bool{
"я": true, "меня": true, "мне": true, "мной": true, "мой": true,
"моя": true, "моё": true, "мое": true, "мои": true, "моего": true,
"моей": true, "моих": true, "моим": true, "себя": true, "свой": true,
"своя": true, "свои": true, "своего": true, "мною": true,
"i": true, "me": true, "my": true, "mine": true, "myself": true,
}
// RecallAllowed is the second half of the recall gate (#470). A hit that
// cleared the score and margin gate may still be about something else
// entirely: the held-out fixture puts the right note at 0.791-0.890 and the
// must-be-silent cases at 0.795-0.835, so no threshold sits between them, and
// a note about his slow network answered "почему небо синее?".
//
// The veto applies only to a question that mentions nothing of his. That
// restriction is what keeps the fix from costing more than it saves: recall
// exists to find the note whose words he no longer remembers, and demanding a
// shared word of every recall silenced four true recalls on the fixture to
// kill one false one. A question about his own life keeps the embedder alone
// as its judge. A question about the world has to name something the memory
// actually mentions.
func RecallAllowed(query, text string) bool {
if mentionsHim(query) {
return true
}
return SharesContentWord(query, text)
}
func mentionsHim(query string) bool {
for _, w := range strings.FieldsFunc(strings.ToLower(query), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
}) {
if firstPerson[w] {
return true
}
}
return false
}
// SharesContentWord reports whether query and text have at least one topic
// word in common, after dropping the words that carry no topic. Stems are
// compared, so the note and the question do not have to inflect alike.
func SharesContentWord(query, text string) bool {
q := contentWords(query)
if len(q) == 0 {
// Nothing to compare — a question made entirely of filler. The score
// gate is then the only judge it can have.
return true
}
t := contentWords(text)
for _, a := range q {
for _, b := range t {
if a == b || sameStem(a, b) {
return true
}
}
}
return false
}
func contentWords(s string) []string {
var out []string
for _, w := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
}) {
if !stopwords[w] {
out = append(out, w)
}
}
return out
}
// sameStem is inflection and derivation tolerance: Russian marks case and
// tense on the ending, and the note and the question rarely use the same form.
// "воду" and "вода" are the same water, "кормить" and "корм" the same feeding.
// All but the last rune of the shorter word must match, and never fewer than
// three, which is what keeps "сеть" clear of "сеанс".
func sameStem(a, b string) bool {
ar, br := []rune(a), []rune(b)
n := min(len(ar), len(br)) - 1
if n < 3 {
return false
}
return string(ar[:n]) == string(br[:n])
}
+40
View File
@@ -0,0 +1,40 @@
package memory
import "testing"
func TestRecallAllowed(t *testing.T) {
cases := []struct {
name string
query, text string
want bool
}{
// The #470 shape: a world question and a note about his box.
{"world question, unrelated note", "почему небо синее", "сеть какая-то медленная", false},
{"world question, unrelated fact", "какая столица Франции", "какая последняя версия языка Go", false},
{"silent fixture case", "во сколько отходит поезд", "бэкап запускается в три ночи", false},
// A world question that does name the topic keeps its answer.
{"world question, same topic", "какой поезд идёт в Минск", "поезда в Минск ходят утром", true},
// A question about his own life is judged by the embedder alone,
// because recall exists for words he no longer remembers.
{"about him, no shared word", "во сколько я обычно засыпаю", "ложусь около одиннадцати", true},
{"about him, english", "which colour scheme do i like", "тёмная тема везде", true},
// Inflection must not break a match.
{"inflected", "чем кормить кота", "корм для кота в шкафу", true},
}
for _, c := range cases {
if got := RecallAllowed(c.query, c.text); got != c.want {
t.Errorf("%s: RecallAllowed(%q, %q) = %v, want %v", c.name, c.query, c.text, got, c.want)
}
}
}
// A question made only of filler has no topic word to match on, and the score
// gate is then the only judge it can have.
func TestRecallAllowedFallsBackWhenNothingToCompare(t *testing.T) {
if !RecallAllowed("что это", "сеть какая-то медленная") {
t.Error("a question with no content word must not be vetoed")
}
}
+11 -3
View File
@@ -378,7 +378,7 @@ func scoreCase(ctx context.Context, emb router.Embedder, newStore NewStore, minS
if len(hits) > 1 {
o.Margin = hits[0].Score - hits[1].Score
}
o.Recalled = bestRecall(hits, minScore, minMargin)
o.Recalled = bestRecall(c.Query, hits, minScore, minMargin)
}
for i, h := range hits {
if h.ID != c.Want {
@@ -424,11 +424,19 @@ func rankNote(inTop3 bool) string {
// is not importable; recalleval_test.go asserts the two agree in behaviour.
// The daemon returns the whole hit (a note and a fact are said differently);
// the harness only scores what came back, so it keeps returning the text.
func bestRecall(results []memory.Result, minScore, minMargin float64) string {
// bestRecall mirrors the daemon's gate in cmd/mavend/recall.go, including the
// topic veto added for #470: a score that clears the gate still has to be
// about what he asked. Keep the two in step — a fixture that measures a
// weaker gate than the daemon runs flatters it.
func bestRecall(query string, results []memory.Result, minScore, minMargin float64) string {
if !memory.Confident(results, minScore, minMargin) {
return ""
}
return results[0].Meta["text"]
text := results[0].Meta["text"]
if !memory.RecallAllowed(query, text) {
return ""
}
return text
}
func bump(m map[string]TagStat, key string, pass bool) {
+15 -8
View File
@@ -140,21 +140,22 @@ func words(s string) []string {
// TestBestRecallMatchesDaemon — the harness duplicates bestRecall from
// cmd/mavend/recall.go (package main is not importable). This pins the copy to
// the original's three rules: no hits, below the gate, or no text ⇒ silence.
// the original's rules: no hits, below the gate, no text, or no shared topic
// word ⇒ silence.
func TestBestRecallMatchesDaemon(t *testing.T) {
if got := bestRecall(nil, 0.55, 0); got != "" {
if got := bestRecall("чай", nil, 0.55, 0); got != "" {
t.Errorf("no hits: got %q, want silence", got)
}
low := []memory.Result{{ID: "a", Score: 0.4, Meta: map[string]string{"text": "чай"}}}
if got := bestRecall(low, 0.55, 0); got != "" {
if got := bestRecall("чай", low, 0.55, 0); got != "" {
t.Errorf("below gate: got %q, want silence", got)
}
noText := []memory.Result{{ID: "a", Score: 0.9, Meta: map[string]string{}}}
if got := bestRecall(noText, 0.55, 0); got != "" {
if got := bestRecall("чай", noText, 0.55, 0); got != "" {
t.Errorf("no text: got %q, want silence", got)
}
ok := []memory.Result{{ID: "a", Score: 0.9, Meta: map[string]string{"text": "чай"}}}
if got := bestRecall(ok, 0.55, 0); got != "чай" {
if got := bestRecall("чай", ok, 0.55, 0); got != "чай" {
t.Errorf("above gate: got %q, want %q", got, "чай")
}
// Margin: a close runner-up means the embedder cannot tell the two apart,
@@ -163,17 +164,23 @@ func TestBestRecallMatchesDaemon(t *testing.T) {
{ID: "a", Score: 0.86, Meta: map[string]string{"text": "чай"}},
{ID: "b", Score: 0.85, Meta: map[string]string{"text": "кофе"}},
}
if got := bestRecall(close, 0.55, 0.03); got != "" {
if got := bestRecall("чай", close, 0.55, 0.03); got != "" {
t.Errorf("thin margin: got %q, want silence", got)
}
if got := bestRecall(close, 0.55, 0); got != "чай" {
if got := bestRecall("чай", close, 0.55, 0); got != "чай" {
t.Errorf("margin off: got %q, want %q", got, "чай")
}
// The topic veto (#470): the score is fine and the note is about
// something else.
offTopic := []memory.Result{{ID: "a", Score: 0.9, Meta: map[string]string{"text": "сеть какая-то медленная"}}}
if got := bestRecall("почему небо синее", offTopic, 0.55, 0); got != "" {
t.Errorf("off topic: got %q, want silence", got)
}
clear := []memory.Result{
{ID: "a", Score: 0.86, Meta: map[string]string{"text": "чай"}},
{ID: "b", Score: 0.70, Meta: map[string]string{"text": "кофе"}},
}
if got := bestRecall(clear, 0.55, 0.03); got != "чай" {
if got := bestRecall("чай", clear, 0.55, 0.03); got != "чай" {
t.Errorf("wide margin: got %q, want %q", got, "чай")
}
}
+64
View File
@@ -0,0 +1,64 @@
package router
import "strings"
// interrogatives — the question words that mark an utterance as asking rather
// than telling. Tokenized, never substring: "что" inside "чтобы" and "как"
// inside "какао" are not questions.
var interrogatives = []string{
"что", "чего", "какой", "какая", "какое", "какие", "каких",
"кто", "кого", "кому", "чей", "почему", "зачем", "отчего",
"где", "куда", "откуда", "когда", "сколько", "как",
"what", "who", "whom", "why", "when", "where", "which", "how",
}
// narrativeRequests — "tell me about X" asks for knowledge Maven does not
// hold about him. It carries no question mark and no interrogative, which is
// how "расскажи про битву при Ватерлоо" reached the fact store (#470).
var narrativeRequests = []string{
"расскажи", "объясни", "опиши", "перечисли",
"tell", "explain", "describe",
}
// captureVerbs — an explicit instruction to record something. These win over
// every test below, because "запиши что я пил воду" contains an interrogative
// and is still a capture: the word he said is "запиши".
var captureVerbs = []string{
"запиши", "запомни", "отметь", "заметь", "добавь", "сохрани",
"note", "remember", "log", "save",
}
// IsQuestionShaped reports whether text asks for something rather than
// records it. It is a deterministic offline test over tokens, so it costs
// nothing and never depends on the model that produced the routing decision.
//
// It exists because a mis-routed question used to be persisted as a fact
// about the owner, with the model's invented answer as the value (#470). The
// predicate is deliberately blunt: refusing to store a question is cheap and
// reversible, storing an invented fact about him is neither.
func IsQuestionShaped(text string) bool {
t := strings.TrimSpace(text)
if t == "" {
return false
}
toks := planTokens(t)
for _, v := range captureVerbs {
if hasTok(toks, v) {
return false
}
}
if strings.HasSuffix(t, "?") {
return true
}
for _, w := range interrogatives {
if hasTok(toks, w) {
return true
}
}
for _, w := range narrativeRequests {
if hasTok(toks, w) {
return true
}
}
return false
}
+48
View File
@@ -0,0 +1,48 @@
package router
import "testing"
func TestIsQuestionShaped(t *testing.T) {
// The seven utterances #470 recorded, plus the captures that must keep
// working. A capture misread as a question loses a fact; a question
// misread as a capture poisons recall, so the captures are the ones worth
// pinning here.
cases := []struct {
text string
want bool
}{
{"какая последняя версия языка Go?", true},
{"что дальше?", true},
{"расскажи про битву при Ватерлоо", true},
{"почему небо синее?", true},
{"какая столица Австралии?", true},
{"кто такой Никола Тесла?", true},
{"сколько стоит доллар", true},
{"who is the premier of Japan", true},
{"объясни линии Фраунгофера", true},
{"запиши что я пил воду", false},
{"запомни какая у меня машина", false},
{"отметь что я поужинал", false},
{"поужинал", false},
{"я выпил кофе", false},
{"вода", false},
{"привет", false},
{"", false},
}
for _, c := range cases {
if got := IsQuestionShaped(c.text); got != c.want {
t.Errorf("IsQuestionShaped(%q) = %v, want %v", c.text, got, c.want)
}
}
}
// Substring matching is what made the day-plan predicates wrong before, and
// this predicate gates a write, so it gets the same guard.
func TestIsQuestionShapedIsTokenized(t *testing.T) {
for _, text := range []string{"чтобы не забыть, я полил кактус", "какао выпил"} {
if IsQuestionShaped(text) {
t.Errorf("IsQuestionShaped(%q) = true; a question word inside a longer word is not a question", text)
}
}
}
+62
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"time"
@@ -46,6 +47,39 @@ func (s *Store) WriteFact(ctx context.Context, ts time.Time, kind FactKind, key,
return id, nil
}
// FactRecallText is the text a fact is indexed under and read back as (#493).
//
// It used to be the utterance that wrote the fact, so recall of ANY
// voice-tapped fact answered with the sentence he said instead of the value
// stored: `go_version = 1.20` was indexed as "какая последняя версия языка
// Go?", and that question is what came back. The poisoned rows made the defect
// visible; the shape was wrong for legitimate facts too.
//
// The key is spoken with its underscores dropped, because a key is written for
// the store and this string is read out loud.
func FactRecallText(key, value string) string {
spoken := strings.TrimSpace(strings.ReplaceAll(key, "_", " "))
v := strings.TrimSpace(DecodeFactValue(value))
switch {
case v == "":
return spoken
case spoken == "":
return v
}
return spoken + " — " + v
}
// DecodeFactValue unwraps a stored value for reading. The column holds raw json
// when the writer serialized one (SetValue, CorrectValue) and a plain string
// when it did not (a voice tap), so a reader that wants the text handles both.
func DecodeFactValue(value string) string {
var s string
if err := json.Unmarshal([]byte(value), &s); err == nil {
return s
}
return value
}
// LatestFact returns the latest non-voided fact for key, or ErrNoFact.
// "Non-voided" = no later row has voids_id pointing at it. We resolve this by
// taking the newest row whose id is not referenced by any voids_id.
@@ -290,6 +324,20 @@ func (s *Store) CorrectValue(ctx context.Context, key, source string, value any,
if err != nil {
return 0, fmt.Errorf("last insert id: %w", err)
}
// The same repair a void needs, for the same reason (#493). A correction
// supersedes the value, and the vector still holds the old one, so recall
// kept answering with the value he had just corrected. Dropping it costs
// the key its recall vector until the fact is tapped again: this layer has
// no embedder, and a missing vector loses a question while a stale one
// answers it wrongly.
//
// Best-effort: the corrected row is committed, and a correction that lands
// beats one that fails on cleanup.
if n, derr := s.VectorMemory().DeletePrefix(ctx, "fact:"+key+":"); derr != nil {
log.Printf("store: correct %q: memory vectors survive: %v", key, derr)
} else if n > 0 {
log.Printf("store: correct %q: dropped %d superseded memory vector(s)", key, n)
}
return newID, nil
}
@@ -332,6 +380,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:<key>:<unix>`, 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
}
+177
View File
@@ -0,0 +1,177 @@
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
// metaKeyFactVectorShape names the shape the stored fact vectors were written
// in. It exists so the repair below runs once per box instead of on every
// start: the rows it fixes were written by a code path that no longer exists,
// and once fixed nothing writes that shape again.
const metaKeyFactVectorShape = "fact_vector_shape"
// factVectorShapeFact is the shape FactRecallText produces. Anything else in
// the marker (including nothing, which is every box written before #493) means
// the fact vectors still hold utterances.
const factVectorShapeFact = "fact-text (#493)"
// FactVectorRepair is what one repair run did, for logging.
type FactVectorRepair struct {
Skipped bool // marker already matched — nothing to do
Rewritten int // rows re-embedded from the fact they name
Dropped int // rows deleted: voided, superseded, or naming no fact at all
Kept int // rows already holding the right text
Took time.Duration
}
// RepairFactVectors brings the fact rows of memory_vectors in line with the
// facts they name, and is the operator recovery a poisoned box had no path to
// (#470 point 4, #493).
//
// Three defects put wrong text in that index, and all three are write-path
// fixes that do nothing for rows already stored:
//
// - the indexed text was the utterance, so every fact row reads back a
// sentence rather than a value;
// - a void left its vector behind, so retracted junk kept answering;
// - a correction left its vector behind, so the superseded value did.
//
// So each fact row is resolved against the fact store and one of three things
// happens. It is dropped when the key has no fact, when the newest row for the
// key is a void marker, or when a newer vector for the same key exists — a
// superseded value has no business claiming a turn. It is re-embedded when its
// text is not what FactRecallText says the fact is. Otherwise it is left alone.
//
// Idempotent, and safe to interrupt: every step compares before writing and the
// marker is written last, so a run that dies partway is simply redone.
func (s *Store) RepairFactVectors(ctx context.Context, embed EmbedFunc) (FactVectorRepair, error) {
start := time.Now()
var res FactVectorRepair
shape, err := s.Meta(ctx, metaKeyFactVectorShape)
if err != nil {
return res, err
}
if shape == factVectorShapeFact {
res.Skipped = true
res.Took = time.Since(start)
return res, nil
}
rows, err := s.db.QueryContext(ctx, `SELECT id, meta FROM memory_vectors`)
if err != nil {
return res, fmt.Errorf("repair fact vectors: read: %w", err)
}
type factVec struct {
id, key string
meta map[string]string
ts int64
}
var vecs []factVec
newest := map[string]int64{} // key → newest ts seen for it
for rows.Next() {
var id, metaJSON string
if err := rows.Scan(&id, &metaJSON); err != nil {
rows.Close()
return res, fmt.Errorf("repair fact vectors: row: %w", err)
}
meta := map[string]string{}
if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil {
rows.Close()
return res, fmt.Errorf("repair fact vectors: meta for %q: %w", id, err)
}
if meta["type"] != "fact" {
continue
}
key, ts, ok := splitFactVectorID(id)
if !ok {
continue
}
vecs = append(vecs, factVec{id: id, key: key, meta: meta, ts: ts})
if ts > newest[key] {
newest[key] = ts
}
}
rows.Close()
if err := rows.Err(); err != nil {
return res, fmt.Errorf("repair fact vectors: rows: %w", err)
}
for _, v := range vecs {
drop := v.ts < newest[v.key]
var want string
if !drop {
f, ferr := s.LatestFact(ctx, v.key)
switch {
case errors.Is(ferr, ErrNoFact):
drop = true
case ferr != nil:
return res, fmt.Errorf("repair fact vectors: fact %q: %w", v.key, ferr)
case DecodeFactValue(f.Value) == "voided":
drop = true
default:
want = FactRecallText(v.key, f.Value)
}
}
if drop {
if err := s.VectorMemory().Delete(ctx, v.id); err != nil {
return res, err
}
res.Dropped++
continue
}
if v.meta["text"] == want {
res.Kept++
continue
}
vec, err := embed(ctx, want)
if err != nil {
return res, fmt.Errorf("repair fact vectors: embed %q: %w", v.id, err)
}
// The whole meta blob is rewritten in Go rather than patched in SQL,
// because json_set needs the JSON1 extension and this store is opened
// through sqlcipher.
v.meta["text"] = want
metaJSON, err := json.Marshal(v.meta)
if err != nil {
return res, fmt.Errorf("repair fact vectors: meta %q: %w", v.id, err)
}
if _, err := s.db.ExecContext(ctx,
`UPDATE memory_vectors SET vec = ?, meta = ? WHERE id = ?`,
encodeVec(vec), string(metaJSON), v.id); err != nil {
return res, fmt.Errorf("repair fact vectors: write %q: %w", v.id, err)
}
res.Rewritten++
}
if err := s.SetMeta(ctx, metaKeyFactVectorShape, factVectorShapeFact); err != nil {
return res, err
}
res.Took = time.Since(start)
return res, nil
}
// splitFactVectorID reads the key and write time back out of a fact vector's
// id, which the write path builds as `fact:<key>:<unix>`. A key may hold a
// colon, the timestamp may not, so the split is from the right.
func splitFactVectorID(id string) (key string, ts int64, ok bool) {
rest, found := strings.CutPrefix(id, "fact:")
if !found {
return "", 0, false
}
cut := strings.LastIndex(rest, ":")
if cut <= 0 {
return "", 0, false
}
ts, err := strconv.ParseInt(rest[cut+1:], 10, 64)
if err != nil {
return "", 0, false
}
return rest[:cut], ts, true
}
+151
View File
@@ -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)
}
}
+21
View File
@@ -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(`\`, `\\`, `%`, `\%`, `_`, `\_`)
+64
View File
@@ -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:<key>:<unix>.
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)
}
}