Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 453919db20 | |||
| ad60e10e95 | |||
| 1528697287 | |||
| dbdab2d570 |
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// actionFact handles router.IntentFact: persist a tapped self-fact, index
|
// actionFact handles router.IntentFact: persist a tapped self-fact, index
|
||||||
@@ -63,17 +64,25 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
|||||||
log.Printf("voice: write fact: %v", err)
|
log.Printf("voice: write fact: %v", err)
|
||||||
return "не получилось сохранить факт."
|
return "не получилось сохранить факт."
|
||||||
}
|
}
|
||||||
// Index the fact utterance in long-term memory (best-effort, must not
|
// Index the fact in long-term memory (best-effort, must not fail the fact
|
||||||
// fail the fact write). Facts aren't in the notes table, so this is the
|
// write). Facts aren't in the notes table, so this is the only recall path
|
||||||
// only recall path for them — "когда я пил воду?" reads back from here.
|
// 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 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)
|
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{
|
} else if err := h.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{
|
||||||
"source": "voice",
|
"source": "voice",
|
||||||
"type": "fact",
|
"type": "fact",
|
||||||
"text": dec.Utterance,
|
"text": text,
|
||||||
"ts": strconv.FormatInt(now.Unix(), 10),
|
"utterance": dec.Utterance,
|
||||||
|
"ts": strconv.FormatInt(now.Unix(), 10),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("voice: memory insert fact: %v", err)
|
log.Printf("voice: memory insert fact: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,22 @@ func TestActionFact_ExplicitCaptureStillWrites(t *testing.T) {
|
|||||||
if f.Confidence != 1.0 {
|
if f.Confidence != 1.0 {
|
||||||
t.Errorf("confidence = %v, want 1.0 for a value he said", f.Confidence)
|
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) {
|
func TestFactConfidence(t *testing.T) {
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
emb = router.NewHashEmbedder(1024)
|
emb = router.NewHashEmbedder(1024)
|
||||||
}
|
}
|
||||||
w.embedder = emb
|
w.embedder = emb
|
||||||
|
repairFactVectors(dataStore, emb)
|
||||||
checkStoredEmbedder(dataStore, emb)
|
checkStoredEmbedder(dataStore, emb)
|
||||||
|
|
||||||
// ----- tool executor (the enabled act allowlist, store-backed) -----
|
// ----- 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)
|
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
|
// reembedOnStart is the -reembed flag (set in run()). Opt-in on purpose: see
|
||||||
// runReembed.
|
// runReembed.
|
||||||
var reembedOnStart bool
|
var reembedOnStart bool
|
||||||
|
|||||||
+15
-6
@@ -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
|
(`internal/store/memory.go:145` is a real `DELETE`, not a tombstone). Vision is
|
||||||
19/19, speaker 22/22, media 16/16.
|
19/19, speaker 22/22, media 16/16.
|
||||||
|
|
||||||
**470 got worse.** Both poisoned facts show `voided` on `/history`, and the
|
**470 got worse, then closed.** Both poisoned facts showed `voided` on
|
||||||
defect survives. Re-measured at 15:42, after four restarts: `почему небо синее?`
|
`/history` and the defect survived. Re-measured at 15:42, after four restarts:
|
||||||
still answers `какая последняя версия языка Go?` with no `search:` line. What
|
`почему небо синее?` still answered `какая последняя версия языка Go?` with no
|
||||||
comes back is the question he typed, not the value the fact held. So the poison
|
`search:` line. What came back was the question he typed, not the value the fact
|
||||||
is a vector in the memory index, and `revert` does not remove it. There is
|
held. So the poison was a vector in the memory index, and `revert` did not
|
||||||
currently no documented way to repair a poisoned box.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,39 @@ func (s *Store) WriteFact(ctx context.Context, ts time.Time, kind FactKind, key,
|
|||||||
return id, nil
|
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.
|
// 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
|
// "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.
|
// taking the newest row whose id is not referenced by any voids_id.
|
||||||
@@ -291,6 +324,20 @@ func (s *Store) CorrectValue(ctx context.Context, key, source string, value any,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("last insert id: %w", err)
|
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
|
return newID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user