Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6f47fbeb6 | |||
| 2e9b9ec1cf | |||
| bfb57c3148 | |||
| d1f6f6355f | |||
| 92ecb691de |
@@ -124,6 +124,55 @@ Two caveats worth saying out loud:
|
||||
Phrasing was **not** measured. Whether thinking helps there is still open, and now also blocked
|
||||
on the same "can we even turn it off" question.
|
||||
|
||||
## Clock and calendar rule — 31-07-2026 (Vikunja #374)
|
||||
|
||||
`routeSystem` never said whether "который час" or "какое число завтра" are `system` or
|
||||
`query`, and `system→query ×4` showed up in every run. The rule added says: the clock and the
|
||||
calendar date themselves are `system`; what is *written in* the calendar or in memory
|
||||
("что у меня завтра", "какие есть напоминания") stays `query`; and a time named inside a
|
||||
request ("напомни завтра…") is just a detail of the request, not a reason for `system`.
|
||||
|
||||
That split is not a preference. In `cmd/mavend/voice.go` only `replySystem` owns the clock and
|
||||
the date formatter, so a clock question routed to `query` falls into the embedder + note RAG
|
||||
and answers "не знаю". The agenda, on the other hand, is answered by `ParseCalendarDate` +
|
||||
`CalendarEvents` *inside* the `query` branch, so that side has to stay `query`. The rule sits
|
||||
above the question test because every one of these utterances carries a question word and a
|
||||
later rule would never be reached.
|
||||
|
||||
The fixture is now 77 cases: one calendar-agenda case was added
|
||||
(`ru-query-019` "что у меня стоит в календаре на послезавтра", intent `query`) specifically so
|
||||
an over-broad system rule cannot pass unnoticed. The clock/date cases (`ru-sys-001/002/005`,
|
||||
`en-sys-001`) already existed.
|
||||
|
||||
Three runs, same box, back to back, never concurrently:
|
||||
|
||||
| | baseline | first rule (too broad) | rule as committed |
|
||||
|---|---|---|---|
|
||||
| llm-only intent-only | 59.2% (45/76) | 54.5% (42/77) | 59.7% (46/77) |
|
||||
| llm-only full | 38.2% | 35.1% | 39.0% |
|
||||
| llm-only route errors | 3 | 4 | 5 |
|
||||
| llm-only p50 | 1.09s | 0.91s | 0.93s |
|
||||
| cascade+llm intent-only | 61.8% (47/76) | 58.4% | 62.3% (48/77) |
|
||||
| cascade+llm full | 57.9% | 54.5% | 59.7% |
|
||||
| cascade+llm route errors | 0 | 0 | 0 |
|
||||
| cascade+llm p50 | 0.91s | 0.80s | 1.04s |
|
||||
|
||||
**The targeted bug is fixed and the headline number did not move.** `system→query ×4` is gone
|
||||
in both LLM configurations — the `time` and `date` tags go from 0/2 and 0/2 to 2/2 and 2/2 —
|
||||
but the model then over-applies the rule, and `query→system ×5` plus `reminder→system ×2`
|
||||
appear where they did not exist before. Net accuracy is a wash, inside the noise of a 77-case
|
||||
fixture.
|
||||
|
||||
The first attempt is shown because it is the honest history: it said "спрашивает время, дату
|
||||
или день недели → system" with no scope, which swept up reminders, and it cost 3-5 points. It
|
||||
was tightened once, on the reasoning that a rule capturing "напомни завтра в 7" is simply
|
||||
wrong, and not tuned further. The remaining `query/reminder → system` over-trigger is a new,
|
||||
separate weakness of the sub-1B model and deserves its own task rather than more prompt
|
||||
kneading against a held-out fixture.
|
||||
|
||||
The rule is kept. It is correct about what the daemon can answer, and the failure it replaces
|
||||
was silent ("не знаю" to "который час") while the one it introduces is loud.
|
||||
|
||||
## Findings
|
||||
|
||||
### 1. The resident model does route better — 50.0% vs 36.8%
|
||||
|
||||
@@ -189,7 +189,9 @@ func (l *lockedAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStat
|
||||
func run(args []string) error {
|
||||
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
|
||||
wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)")
|
||||
reembed := flag.Bool("reembed", false, "re-embed every stored note and fact with the configured embedder, then serve normally (run once after an embedder swap)")
|
||||
flag.CommandLine.Parse(args)
|
||||
reembedOnStart = *reembed
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
+47
-3
@@ -1769,26 +1769,70 @@ func jsonStringImpl(s string) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// reembedOnStart is the -reembed flag (set in run()). Opt-in on purpose: see
|
||||
// runReembed.
|
||||
var reembedOnStart bool
|
||||
|
||||
// checkStoredEmbedder compares the embedder we just loaded with the one that
|
||||
// wrote the vectors already in the DB (Vikunja #378).
|
||||
//
|
||||
// The two models we have both make 384-dim vectors, so a size check catches
|
||||
// nothing: after a swap, recall silently compares vectors from different
|
||||
// spaces and the scores are noise. So we say it out loud. Nothing is changed
|
||||
// here — recall keeps running exactly as before until the backfill lands.
|
||||
// spaces and the scores are noise. So we say it out loud. Recall itself is not
|
||||
// changed here — the fix is `mavend -reembed`.
|
||||
func checkStoredEmbedder(dataStore *store.Store, emb router.Embedder) {
|
||||
if dataStore == nil {
|
||||
return
|
||||
}
|
||||
current := router.EmbedderID(emb)
|
||||
if reembedOnStart {
|
||||
runReembed(dataStore, emb, current)
|
||||
return
|
||||
}
|
||||
stored, mismatch, err := dataStore.CheckEmbedder(context.Background(), current)
|
||||
if err != nil {
|
||||
log.Printf("voice: embedder marker check failed: %v", err)
|
||||
return
|
||||
}
|
||||
if mismatch {
|
||||
log.Printf("voice: WARNING embedder MISMATCH — stored vectors were written by %q but the configured embedder is %q; recall scores are noise until the notes and facts are re-embedded (Vikunja #378)", stored, current)
|
||||
log.Printf("voice: WARNING embedder MISMATCH — stored vectors were written by %q but the configured embedder is %q; recall scores are noise until the notes and facts are re-embedded — run `mavend -reembed` once (Vikunja #378)", stored, current)
|
||||
return
|
||||
}
|
||||
log.Printf("voice: embedder marker ok (%s)", current)
|
||||
}
|
||||
|
||||
// runReembed is the one-shot backfill behind -reembed.
|
||||
//
|
||||
// Why a flag and not automatic on mismatch: the embedder is ONNX on the
|
||||
// laptop's CPU, so a few thousand notes is minutes of work. Doing that silently
|
||||
// inside a normal start would look like the daemon hanging on boot. So the user
|
||||
// runs it once, deliberately, after an embedder swap; the mismatch warning
|
||||
// above tells them to. It re-embeds, logs what it did, and then the daemon
|
||||
// carries on serving as usual — no separate binary, no second start needed.
|
||||
func runReembed(dataStore *store.Store, emb router.Embedder, current string) {
|
||||
log.Printf("voice: re-embedding stored notes and facts with %s — this can take a few minutes, do not interrupt", current)
|
||||
res, err := dataStore.ReembedAll(context.Background(), current,
|
||||
// EmbedPassage, not EmbedQuery: these are stored texts being searched
|
||||
// FOR, which is the side they were written with.
|
||||
func(ctx context.Context, text string) ([]float32, error) {
|
||||
return router.EmbedPassage(ctx, emb, text)
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("voice: re-embed FAILED, nothing was changed and no marker was written — safe to run again: %v", err)
|
||||
return
|
||||
}
|
||||
if res.Skipped {
|
||||
log.Printf("voice: re-embed skipped — the stored vectors were already written by %s", current)
|
||||
return
|
||||
}
|
||||
log.Printf("voice: re-embed done — %d notes in the notes table, %d notes and %d facts in the memory index, took %s; stored vectors now belong to %s",
|
||||
res.Notes, res.MemNotes, res.Facts, res.Took.Round(time.Second), current)
|
||||
|
||||
// A row with no text cannot be re-embedded, so its vector is still the old
|
||||
// model's noise while the marker now says everything is current. Both write
|
||||
// paths always store the text, so this should be zero — say it loudly
|
||||
// rather than bury it in the line above if it ever isn't.
|
||||
if res.NoText > 0 {
|
||||
log.Printf("voice: WARNING %d stored rows had no text, so their vectors could not be re-embedded and are still noise; they will never match anything useful (Vikunja #378)", res.NoText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
{ "id": "ru-query-011", "utterance": "почему сервер тормозит", "lang": "ru", "intent": "query", "tags": ["homelab", "hard"], "note": "diagnostic question, not a chat opener" },
|
||||
{ "id": "ru-query-012", "utterance": "какие заметки я оставил про полив", "lang": "ru", "intent": "query", "tags": ["recall"] },
|
||||
{ "id": "ru-query-013", "utterance": "во сколько у меня встреча", "lang": "ru", "intent": "query", "tags": ["calendar"] },
|
||||
{ "id": "ru-query-019", "utterance": "что у меня стоит в календаре на послезавтра", "lang": "ru", "intent": "query", "tags": ["calendar", "hard"], "note": "agenda, not the clock: the daemon answers this from CalendarEvents inside the query branch, so the clock/date system rule must not swallow it" },
|
||||
{ "id": "ru-query-014", "utterance": "я успеваю до дедлайна", "lang": "ru", "intent": "query", "tags": ["hard", "no-question-word"] },
|
||||
{ "id": "ru-query-015", "utterance": "сколько я прошёл шагов", "lang": "ru", "intent": "query", "tags": ["aggregate"] },
|
||||
{ "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" },
|
||||
|
||||
@@ -44,10 +44,21 @@ ws ::= [ \t\n]*
|
||||
// Changed again 31-07-2026: added the "unknown" escape hatch so the model can
|
||||
// admit it cannot route (Vikunja #359).
|
||||
//
|
||||
// Changed again 31-07-2026: added the clock/calendar rule (Vikunja #374). The
|
||||
// prompt never said which side "который час" or "какое число завтра" belong on,
|
||||
// so the model guessed — `system→query ×4` in every eval run. The rule sits
|
||||
// above the question test on purpose: these utterances all carry a question
|
||||
// word, so a later rule would never be reached. The boundary is what the
|
||||
// daemon can actually answer: only replySystem in cmd/mavend/voice.go owns the
|
||||
// clock and the calendar formatter, while the agenda ("что у меня завтра") is
|
||||
// answered inside the query branch, so that side stays query.
|
||||
//
|
||||
// The training workspace keeps its own copy of this prompt for relabelling, and
|
||||
// `llm/check_prompt_parity.py` there compares the two. That copy is in another
|
||||
// repo and was not touched, so parity will fail until it gets the same edits —
|
||||
// both the rule reorder and the "unknown" wording (Vikunja #362).
|
||||
// both the rule reorder and the "unknown" wording (Vikunja #362) — and now the
|
||||
// clock/calendar rule too. The training workspace is not checked out on this
|
||||
// box at all, so it could not be updated here; #362 still covers the catch-up.
|
||||
const routeSystem = `Классифицируй ровно одно сообщение пользователя. Верни ОДИН JSON-массив действий.
|
||||
|
||||
Ровно одно намерение: fact, reminder, note, query, act, chat, system.
|
||||
@@ -56,19 +67,21 @@ const routeSystem = `Классифицируй ровно одно сообще
|
||||
Классифицируй по цели пользователя. Порядок решения:
|
||||
1. Хочет напоминание в будущем → reminder
|
||||
2. Явно просит сохранить информацию → note
|
||||
3. Задаёт вопрос: есть вопросительное слово (сколько, что, какой, когда, где, кто, почему, как) или знак «?» → query
|
||||
4. Хочет получить информацию, в том числе о своих же данных → query
|
||||
5. Утверждает: сообщает или обновляет текущее состояние/событие → fact
|
||||
6. Просит выполнить работу → act
|
||||
7. Про ассистента, настройки или память → system
|
||||
8. Реплика — обрывок или указание на неназванное («это», «то», «потом»), и без него непонятно, что именно нужно сделать → unknown
|
||||
9. Иначе → chat
|
||||
3. Спрашивает только «который час» / «какое число» / «какой день недели» — сами часы или календарная дата, без своих данных → system
|
||||
4. Задаёт вопрос: есть вопросительное слово (сколько, что, какой, когда, где, кто, почему, как) или знак «?» → query
|
||||
5. Хочет получить информацию, в том числе о своих же данных → query
|
||||
6. Утверждает: сообщает или обновляет текущее состояние/событие → fact
|
||||
7. Просит выполнить работу → act
|
||||
8. Про ассистента, настройки или память → system
|
||||
9. Реплика — обрывок или указание на неназванное («это», «то», «потом»), и без него непонятно, что именно нужно сделать → unknown
|
||||
10. Иначе → chat
|
||||
|
||||
Различия:
|
||||
- note — сохранить информацию, без напоминания. text = суть.
|
||||
- reminder — уведомить позже. text = что напомнить.
|
||||
- fact — неявное обновление: пользователь сообщает, что что-то в мире изменилось (текущее/изменённое состояние, случившееся событие). key/value.
|
||||
- unknown — редкий случай. Ставь его, только если в самой реплике нет ни предмета, ни действия. Короткая, простая или незнакомая тема — это не причина для unknown: приветствие и болтовня — это chat, вопрос на любую тему — это query, просьба сделать что-то названное — это act.
|
||||
- system против query — часы и календарная дата сами по себе (сколько времени, какое число, какой день недели — можно и про завтра, и про другой город) — это system. А что записано в календаре или в памяти («что у меня завтра», «какие есть напоминания») — это query. Если в реплике есть просьба (напомни, запиши, сделай), то названное время — просто деталь просьбы, и это не system.
|
||||
- query против fact — решает форма реплики, а не тема. Вопрос о состоянии — это query, даже если названо то же самое, что бывает в fact. Только утверждение — это fact.
|
||||
|
||||
Примеры:
|
||||
@@ -82,6 +95,8 @@ const routeSystem = `Классифицируй ровно одно сообще
|
||||
"что такое docker?" → {"intent":"query","text":"что такое docker"}
|
||||
"напиши письмо" → {"intent":"act","verb":"написать письмо"}
|
||||
"очисти память" → {"intent":"system"}
|
||||
"который час?" → {"intent":"system"}
|
||||
"какое число завтра?" → {"intent":"system"}
|
||||
"привет" → {"intent":"chat","text":"привет"}
|
||||
"сделай это" → {"intent":"unknown"}
|
||||
"ну это" → {"intent":"unknown"}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EmbedFunc embeds one piece of stored text. The caller passes
|
||||
// router.EmbedPassage — the STORE side of the query/passage asymmetry, which is
|
||||
// the side every vector in the DB was written with. (Passing the query side
|
||||
// would put the stored vectors in the wrong half of the space and quietly halve
|
||||
// recall.) A func instead of an interface keeps this package free of any
|
||||
// dependency on internal/router.
|
||||
type EmbedFunc func(ctx context.Context, text string) ([]float32, error)
|
||||
|
||||
// BackfillResult is what the re-embed run did, for logging.
|
||||
type BackfillResult struct {
|
||||
Skipped bool // marker already matched — nothing to do
|
||||
Notes int // rows rewritten in the notes table
|
||||
Facts int // fact rows rewritten in memory_vectors
|
||||
MemNotes int // note rows rewritten in memory_vectors
|
||||
NoText int // memory_vectors rows with no text in their meta, left alone
|
||||
Took time.Duration
|
||||
}
|
||||
|
||||
// ReembedAll rewrites every stored vector with the currently configured
|
||||
// embedder and then records that embedder as the one that owns the DB.
|
||||
//
|
||||
// Both places a vector lives are rewritten in the same pass: the `notes` table
|
||||
// `embedding` column and the `memory_vectors` rows (notes AND facts). Doing
|
||||
// only one would leave the two indexes disagreeing, which is worse than leaving
|
||||
// both stale.
|
||||
//
|
||||
// Safe to re-run: if the marker already names the current embedder there is
|
||||
// nothing to fix, so it returns immediately with Skipped set.
|
||||
//
|
||||
// Crash safety: everything — every vector and the marker — happens inside one
|
||||
// transaction. If anything fails or the process dies partway, the transaction
|
||||
// rolls back: no vectors changed and no marker written, so the next run does
|
||||
// the whole job again. The marker is never set unless the full rewrite
|
||||
// committed.
|
||||
func (s *Store) ReembedAll(ctx context.Context, currentID string, embed EmbedFunc) (BackfillResult, error) {
|
||||
start := time.Now()
|
||||
var res BackfillResult
|
||||
|
||||
stored, err := s.Meta(ctx, metaKeyEmbedderID)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if stored == currentID {
|
||||
res.Skipped = true
|
||||
res.Took = time.Since(start)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("reembed: begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback() // no-op once committed
|
||||
|
||||
// ----- notes table -----
|
||||
type noteRow struct {
|
||||
id int64
|
||||
text string
|
||||
}
|
||||
var notes []noteRow
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id, text FROM notes WHERE text != ''`)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("reembed: read notes: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var n noteRow
|
||||
if err := rows.Scan(&n.id, &n.text); err != nil {
|
||||
rows.Close()
|
||||
return res, fmt.Errorf("reembed: note row: %w", err)
|
||||
}
|
||||
notes = append(notes, n)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return res, fmt.Errorf("reembed: notes: %w", err)
|
||||
}
|
||||
|
||||
for _, n := range notes {
|
||||
vec, err := embed(ctx, n.text)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("reembed: embed note %d: %w", n.id, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE notes SET embedding = ? WHERE id = ?`, floatsToBlob(vec), n.id); err != nil {
|
||||
return res, fmt.Errorf("reembed: write note %d: %w", n.id, err)
|
||||
}
|
||||
res.Notes++
|
||||
}
|
||||
|
||||
// ----- memory_vectors (the unified index: notes AND facts) -----
|
||||
// The text to re-embed is the one carried in the row's meta blob, which is
|
||||
// exactly the text that was embedded when the row was written.
|
||||
type vecRow struct {
|
||||
id, text, kind string
|
||||
}
|
||||
var vecs []vecRow
|
||||
rows, err = tx.QueryContext(ctx, `SELECT id, meta FROM memory_vectors`)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("reembed: read memory vectors: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var id, metaJSON string
|
||||
if err := rows.Scan(&id, &metaJSON); err != nil {
|
||||
rows.Close()
|
||||
return res, fmt.Errorf("reembed: memory row: %w", err)
|
||||
}
|
||||
meta := map[string]string{}
|
||||
if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil {
|
||||
rows.Close()
|
||||
return res, fmt.Errorf("reembed: meta for %q: %w", id, err)
|
||||
}
|
||||
if meta["text"] == "" {
|
||||
res.NoText++
|
||||
continue
|
||||
}
|
||||
vecs = append(vecs, vecRow{id: id, text: meta["text"], kind: meta["type"]})
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return res, fmt.Errorf("reembed: memory vectors: %w", err)
|
||||
}
|
||||
|
||||
for _, v := range vecs {
|
||||
vec, err := embed(ctx, v.text)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("reembed: embed %q: %w", v.id, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE memory_vectors SET vec = ? WHERE id = ?`, encodeVec(vec), v.id); err != nil {
|
||||
return res, fmt.Errorf("reembed: write %q: %w", v.id, err)
|
||||
}
|
||||
if v.kind == "fact" {
|
||||
res.Facts++
|
||||
} else {
|
||||
res.MemNotes++
|
||||
}
|
||||
}
|
||||
|
||||
// Same transaction as the rewrite, on purpose: the marker can only exist if
|
||||
// every vector above was written.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO meta (key, value) VALUES (?,?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
metaKeyEmbedderID, currentID); err != nil {
|
||||
return res, fmt.Errorf("reembed: write marker: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return res, fmt.Errorf("reembed: commit: %w", err)
|
||||
}
|
||||
res.Took = time.Since(start)
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// markerVec is a recognisable vector: nothing in these tests writes it except
|
||||
// the backfill, so finding it proves the row really was rewritten.
|
||||
var markerVec = []float32{9, 9, 9}
|
||||
|
||||
func newEmbedder(calls *int) EmbedFunc {
|
||||
return func(_ context.Context, _ string) ([]float32, error) {
|
||||
*calls++
|
||||
return markerVec, nil
|
||||
}
|
||||
}
|
||||
|
||||
// seedOldVectors puts one note (notes table + unified index) and one fact
|
||||
// (unified index only) in the DB, both carrying obviously-old vectors.
|
||||
func seedOldVectors(t *testing.T, s *Store) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
old := []float32{0.1, 0.2, 0.3}
|
||||
id, err := s.WriteNote(ctx, time.Now(), "молоко в холодильнике", old, "voice")
|
||||
if err != nil {
|
||||
t.Fatalf("WriteNote: %v", err)
|
||||
}
|
||||
mem := s.VectorMemory()
|
||||
if err := mem.Insert(ctx, "note:1", old, map[string]string{
|
||||
"type": "note", "text": "молоко в холодильнике",
|
||||
}); err != nil {
|
||||
t.Fatalf("Insert note vector: %v", err)
|
||||
}
|
||||
if err := mem.Insert(ctx, "fact:water:1", old, map[string]string{
|
||||
"type": "fact", "text": "я пил воду",
|
||||
}); err != nil {
|
||||
t.Fatalf("Insert fact vector: %v", err)
|
||||
}
|
||||
_ = id
|
||||
}
|
||||
|
||||
func noteVec(t *testing.T, s *Store) []float32 {
|
||||
t.Helper()
|
||||
var blob []byte
|
||||
if err := s.db.QueryRow(`SELECT embedding FROM notes LIMIT 1`).Scan(&blob); err != nil {
|
||||
t.Fatalf("read note embedding: %v", err)
|
||||
}
|
||||
return blobToFloats(blob)
|
||||
}
|
||||
|
||||
func memVec(t *testing.T, s *Store, id string) []float32 {
|
||||
t.Helper()
|
||||
var blob []byte
|
||||
if err := s.db.QueryRow(`SELECT vec FROM memory_vectors WHERE id = ?`, id).Scan(&blob); err != nil {
|
||||
t.Fatalf("read memory vector %s: %v", id, err)
|
||||
}
|
||||
return decodeVec(blob)
|
||||
}
|
||||
|
||||
func sameVec(a, b []float32) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// The deployed case: old vectors everywhere, no marker. Every vector in both
|
||||
// places must be rewritten and the marker recorded.
|
||||
func TestReembedAllRewritesEveryVector(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
seedOldVectors(t, s)
|
||||
|
||||
calls := 0
|
||||
res, err := s.ReembedAll(ctx, "multilingual-e5-small@384", newEmbedder(&calls))
|
||||
if err != nil {
|
||||
t.Fatalf("ReembedAll: %v", err)
|
||||
}
|
||||
if res.Skipped {
|
||||
t.Fatal("first run should not skip")
|
||||
}
|
||||
if res.Notes != 1 || res.MemNotes != 1 || res.Facts != 1 {
|
||||
t.Fatalf("counts: notes=%d memNotes=%d facts=%d", res.Notes, res.MemNotes, res.Facts)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Fatalf("embedder called %d times, want 3", calls)
|
||||
}
|
||||
if !sameVec(noteVec(t, s), markerVec) {
|
||||
t.Fatalf("notes table not rewritten: %v", noteVec(t, s))
|
||||
}
|
||||
if !sameVec(memVec(t, s, "note:1"), markerVec) {
|
||||
t.Fatal("unified index note row not rewritten")
|
||||
}
|
||||
if !sameVec(memVec(t, s, "fact:water:1"), markerVec) {
|
||||
t.Fatal("unified index fact row not rewritten")
|
||||
}
|
||||
got, err := s.Meta(ctx, metaKeyEmbedderID)
|
||||
if err != nil {
|
||||
t.Fatalf("Meta: %v", err)
|
||||
}
|
||||
if got != "multilingual-e5-small@384" {
|
||||
t.Fatalf("marker = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-running must do nothing at all — not a second pass over the same rows.
|
||||
func TestReembedAllSecondRunIsNoop(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
seedOldVectors(t, s)
|
||||
|
||||
calls := 0
|
||||
if _, err := s.ReembedAll(ctx, "e5@384", newEmbedder(&calls)); err != nil {
|
||||
t.Fatalf("first run: %v", err)
|
||||
}
|
||||
first := calls
|
||||
|
||||
res, err := s.ReembedAll(ctx, "e5@384", newEmbedder(&calls))
|
||||
if err != nil {
|
||||
t.Fatalf("second run: %v", err)
|
||||
}
|
||||
if !res.Skipped {
|
||||
t.Fatal("second run should report Skipped")
|
||||
}
|
||||
if calls != first {
|
||||
t.Fatalf("second run embedded %d more rows, want 0", calls-first)
|
||||
}
|
||||
}
|
||||
|
||||
// A failure partway must leave the DB exactly as it was: no marker, and the old
|
||||
// vectors still in place (one transaction, rolled back).
|
||||
func TestReembedAllPartialFailureLeavesMarkerUnset(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
seedOldVectors(t, s)
|
||||
before := noteVec(t, s)
|
||||
|
||||
calls := 0
|
||||
boom := func(_ context.Context, _ string) ([]float32, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return nil, errors.New("onnx blew up")
|
||||
}
|
||||
return markerVec, nil
|
||||
}
|
||||
if _, err := s.ReembedAll(ctx, "e5@384", boom); err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
got, err := s.Meta(ctx, metaKeyEmbedderID)
|
||||
if err != nil {
|
||||
t.Fatalf("Meta: %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("marker was set to %q after a failed run", got)
|
||||
}
|
||||
if !sameVec(noteVec(t, s), before) {
|
||||
t.Fatal("a failed run left a partially rewritten notes table")
|
||||
}
|
||||
// And the mismatch warning must still fire, so the user knows to re-run.
|
||||
if _, mismatch, err := s.CheckEmbedder(ctx, "e5@384"); err != nil || !mismatch {
|
||||
t.Fatalf("CheckEmbedder after failed backfill: mismatch=%v err=%v", mismatch, err)
|
||||
}
|
||||
}
|
||||
@@ -60,9 +60,9 @@ const EmbedderUnknown = "unknown (written before this marker existed)"
|
||||
// marker was added to catch.
|
||||
// - marker absent and no vectors ⇒ fresh DB, claim it, nothing to fix.
|
||||
//
|
||||
// TODO(#378): on a mismatch, run the one-shot backfill here — re-embed every
|
||||
// stored note and fact text with the current embedder (EmbedPassage side),
|
||||
// write the vectors back, then SetMeta the current id.
|
||||
// On a mismatch the fix is ReembedAll (backfill.go), run explicitly with
|
||||
// `mavend -reembed`. Nothing is re-embedded here: that work is minutes of CPU
|
||||
// on the laptop and must not stall a normal start.
|
||||
func (s *Store) CheckEmbedder(ctx context.Context, currentID string) (stored string, mismatch bool, err error) {
|
||||
stored, err = s.Meta(ctx, metaKeyEmbedderID)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user