98ee701e03
The memory pass ran only after the notes-only gate had already rejected the same note at the same score. Notes and facts share one vector index, so a note that failed there failed again — the branch could only ever return a fact. Now the memory pass runs first: one search over everything Maven remembers, one gate, and the memory that clearly matches best answers (a note gets phrased, a fact is read back). The notes-only pass stays behind it for notes the vector index does not hold. No threshold moved, so the set of questions answered is unchanged — only which memory answers them. Fixture gained two mixed note+fact cases, so the answerable count goes 25 -> 27: hash recall@1 36.0% -> 37.0% (ratchet 0.32 unchanged, comment updated), e5 recall@1 72.0% -> 70.4%, false recall still 1/5. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
159 lines
5.6 KiB
Go
159 lines
5.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/memory"
|
|
"github.com/kami/maven/internal/phraser"
|
|
"github.com/kami/maven/internal/router"
|
|
"github.com/kami/maven/internal/voice"
|
|
)
|
|
|
|
// fixedEmbedder hands back a vector chosen per text, so a test can say exactly
|
|
// how close each stored memory is to the question. The real embedders make
|
|
// scores that are realistic but not controllable, and this test is about the
|
|
// gate, not about the embedder.
|
|
type fixedEmbedder struct{ vecs map[string][]float32 }
|
|
|
|
func (f *fixedEmbedder) Dim() int { return 4 }
|
|
func (f *fixedEmbedder) Close() error { return nil }
|
|
|
|
func (f *fixedEmbedder) Embed(_ context.Context, text string) ([]float32, error) {
|
|
v, ok := f.vecs[text]
|
|
if !ok {
|
|
return nil, fmt.Errorf("fixedEmbedder: no vector for %q", text)
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// scoreVec builds a unit vector whose cosine against the query vector
|
|
// (1,0,0,0) is exactly score.
|
|
func scoreVec(score float64) []float32 {
|
|
rest := math.Sqrt(1 - score*score)
|
|
return []float32{float32(score), float32(rest), 0, 0}
|
|
}
|
|
|
|
// recordingPhraser remembers what the query path handed it to phrase, which is
|
|
// how the test can tell which pass produced the answer.
|
|
type recordingPhraser struct {
|
|
*phraser.Stub
|
|
notes []string
|
|
}
|
|
|
|
func (r *recordingPhraser) PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error) {
|
|
r.notes = notes
|
|
return r.Stub.PhraseQuery(ctx, utterance, notes)
|
|
}
|
|
|
|
// recallCase — one stored memory: its text, how close it is to the question,
|
|
// whether it is a note or a fact, and whether the notes table holds it too.
|
|
type recallCase struct {
|
|
text string
|
|
score float64
|
|
kind string
|
|
}
|
|
|
|
// buildRecallHandler stores the given memories and returns a handler whose
|
|
// query path can be run directly. Notes go into BOTH the notes table and the
|
|
// vector index, which is what the daemon does (voice.go's IntentNote).
|
|
func buildRecallHandler(t *testing.T, question string, mems []recallCase) (*reactiveHandler, *recordingPhraser) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
st := newTestStore(t)
|
|
emb := &fixedEmbedder{vecs: map[string][]float32{question: {1, 0, 0, 0}}}
|
|
mem := memory.NewInMemoryStore()
|
|
now := time.Now()
|
|
|
|
for i, m := range mems {
|
|
vec := scoreVec(m.score)
|
|
emb.vecs[m.text] = vec
|
|
id := fmt.Sprintf("%s:%d", m.kind, i)
|
|
if m.kind == "note" {
|
|
if _, err := st.WriteNote(ctx, now, m.text, vec, "tap:voice"); err != nil {
|
|
t.Fatalf("WriteNote: %v", err)
|
|
}
|
|
}
|
|
if err := mem.Insert(ctx, id, vec, map[string]string{"text": m.text, "type": m.kind}); err != nil {
|
|
t.Fatalf("memory insert: %v", err)
|
|
}
|
|
}
|
|
|
|
phr := &recordingPhraser{Stub: phraser.NewStub()}
|
|
h := &reactiveHandler{
|
|
api: ipc.NewStoreAPI(st),
|
|
embedder: emb,
|
|
replier: voice.NewStubReplier(),
|
|
phraser: phr,
|
|
now: func() time.Time { return now },
|
|
memStore: mem,
|
|
dataStore: st,
|
|
queryMinScore: 0.55,
|
|
queryMinMargin: 0.008,
|
|
weatherProvider: nil,
|
|
}
|
|
return h, phr
|
|
}
|
|
|
|
func askQuery(t *testing.T, h *reactiveHandler, question string) string {
|
|
t.Helper()
|
|
return h.applyAction(context.Background(), router.Decision{
|
|
Intent: router.IntentQuery,
|
|
Utterance: question,
|
|
})
|
|
}
|
|
|
|
// TestQueryRecallNoteCanWin — the note-recall regression (Vikunja #373). Notes
|
|
// and facts share one vector index, and a note that clearly beats everything
|
|
// else must be the answer. Before the fix the memory pass only ran after the
|
|
// notes-only gate had already rejected the same note at the same score, so only
|
|
// a fact could ever come back from it.
|
|
func TestQueryRecallNoteCanWin(t *testing.T) {
|
|
const q = "где молоко"
|
|
|
|
t.Run("a clearly best note answers", func(t *testing.T) {
|
|
h, phr := buildRecallHandler(t, q, []recallCase{
|
|
{text: "молоко стоит в холодильнике", score: 0.90, kind: "note"},
|
|
{text: "выучил пару аккордов", score: 0.50, kind: "note"},
|
|
})
|
|
reply := askQuery(t, h, q)
|
|
if want := "вот что я нашла: молоко стоит в холодильнике"; reply != want {
|
|
t.Errorf("reply %q, want %q", reply, want)
|
|
}
|
|
// One text, the winning memory's — the answer came from the memory
|
|
// pass, not from handing the phraser every note in the table.
|
|
if len(phr.notes) != 1 || phr.notes[0] != "молоко стоит в холодильнике" {
|
|
t.Errorf("phraser got %q, want just the recalled note", phr.notes)
|
|
}
|
|
})
|
|
|
|
// The other half of "one gate over everything": a fact that matches better
|
|
// than the best note now answers, instead of losing to a note that only had
|
|
// to beat other notes.
|
|
t.Run("the better-matching fact answers", func(t *testing.T) {
|
|
h, _ := buildRecallHandler(t, q, []recallCase{
|
|
{text: "молоко стоит в холодильнике", score: 0.80, kind: "note"},
|
|
{text: "купил молоко в среду", score: 0.95, kind: "fact"},
|
|
})
|
|
if reply := askQuery(t, h, q); reply != "купил молоко в среду" {
|
|
t.Errorf("reply %q, want the fact read back", reply)
|
|
}
|
|
})
|
|
|
|
// The gate is untouched: two memories this close mean the embedder cannot
|
|
// tell them apart, and silence still beats a coin flip.
|
|
t.Run("no clear best stays silent", func(t *testing.T) {
|
|
h, _ := buildRecallHandler(t, q, []recallCase{
|
|
{text: "молоко стоит в холодильнике", score: 0.860, kind: "note"},
|
|
{text: "молоко закончилось", score: 0.858, kind: "note"},
|
|
})
|
|
if reply := askQuery(t, h, q); reply != "не знаю." {
|
|
t.Errorf("reply %q, want silence", reply)
|
|
}
|
|
})
|
|
}
|