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
520 lines
18 KiB
Go
520 lines
18 KiB
Go
// Package recalleval is the held-out contract for note recall: can Maven find
|
||
// the right note again when the user asks for it weeks later?
|
||
//
|
||
// Why it sits beside internal/memory rather than inside it: the thing under
|
||
// test is a whole path, not one function — an embedder (internal/router), a
|
||
// vector store (internal/memory or internal/store) and the confidence gate the
|
||
// daemon applies on top (cmd/mavend/recall.go's bestRecall, config's
|
||
// query_min_score). A _test.go file inside internal/memory could not reach the
|
||
// persistent store without an import cycle, and testdata is not reachable from
|
||
// another package's working directory — so the fixture is embedded here and the
|
||
// scorer takes the store as a factory. Same layout and same reasons as
|
||
// internal/router/eval.
|
||
//
|
||
// The fixture is HELD OUT the same way the routing fixture is: a query never
|
||
// repeats its note's wording verbatim beyond ordinary shared vocabulary, and
|
||
// TestFixtureIsParaphrased enforces a floor on how little the two overlap.
|
||
// Scoring recall on a query that is a copy of the note measures string
|
||
// matching, not recall.
|
||
package recalleval
|
||
|
||
import (
|
||
"context"
|
||
_ "embed"
|
||
"encoding/json"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/kami/maven/internal/memory"
|
||
"github.com/kami/maven/internal/router"
|
||
)
|
||
|
||
//go:embed ru_recall_v1.json
|
||
var fixtureJSON []byte
|
||
|
||
// SchemaVersion — the version this package understands. The loader refuses any
|
||
// other version rather than misreading a fixture and reporting a number.
|
||
const SchemaVersion = 1
|
||
|
||
// StoredNote — one thing the user said once, as it lands in the semantic store.
|
||
// Kind is "note" or "fact"; both share the vector index (see
|
||
// cmd/mavend/recall.go), so a fact can legitimately win a recall.
|
||
type StoredNote struct {
|
||
ID string `json:"id"`
|
||
Text string `json:"text"`
|
||
Kind string `json:"kind"`
|
||
}
|
||
|
||
// Case — a small set of notes, one query, and the note that must come back
|
||
// first. Want is empty exactly when the query should recall NOTHING: that lane
|
||
// measures false recall, which is the direction the spec cares about ("a
|
||
// confident wrong fact is worse than a known gap").
|
||
type Case struct {
|
||
ID string `json:"id"`
|
||
Lang string `json:"lang"`
|
||
Notes []StoredNote `json:"notes"`
|
||
Query string `json:"query"`
|
||
Want string `json:"want"`
|
||
Tags []string `json:"tags"`
|
||
Note string `json:"note"`
|
||
}
|
||
|
||
// Answerable reports whether the case expects a recall at all.
|
||
func (c Case) Answerable() bool { return c.Want != "" }
|
||
|
||
// Fixture — the versioned envelope, same shape as the routing fixture.
|
||
//
|
||
// Filler is inserted into EVERY case's store on top of that case's own notes.
|
||
// Without it a case with three notes scores recall@3 = 100% by construction,
|
||
// which measures nothing. A real store holds months of unrelated notes, and the
|
||
// wanted note has to beat all of them.
|
||
type Fixture struct {
|
||
SchemaVersion int `json:"schema_version"`
|
||
Name string `json:"name"`
|
||
Notes []string `json:"notes"`
|
||
Filler []StoredNote `json:"filler"`
|
||
Cases []Case `json:"cases"`
|
||
}
|
||
|
||
// Load returns the embedded fixture.
|
||
func Load() (Fixture, error) {
|
||
var f Fixture
|
||
if err := json.Unmarshal(fixtureJSON, &f); err != nil {
|
||
return Fixture{}, fmt.Errorf("parse fixture: %w", err)
|
||
}
|
||
if f.SchemaVersion != SchemaVersion {
|
||
return Fixture{}, fmt.Errorf("fixture schema_version %d, want %d", f.SchemaVersion, SchemaVersion)
|
||
}
|
||
if len(f.Cases) == 0 {
|
||
return Fixture{}, fmt.Errorf("fixture has no cases")
|
||
}
|
||
return f, nil
|
||
}
|
||
|
||
// NewStore builds an empty store for one case, plus a function to release it.
|
||
// A factory rather than a store because every case needs a clean index — notes
|
||
// from case A must not be visible to case B's query.
|
||
type NewStore func() (memory.Store, func(), error)
|
||
|
||
// InMemory is the NewStore for memory.InMemoryStore — the fallback the daemon
|
||
// uses when there is no database (cmd/mavend/voice.go:224).
|
||
func InMemory() (memory.Store, func(), error) {
|
||
return memory.NewInMemoryStore(), func() {}, nil
|
||
}
|
||
|
||
// Cache wraps an embedder so repeated text is embedded once. The gate sweep
|
||
// scores the same fixture at nine thresholds, and every case re-inserts the
|
||
// filler notes — without this the ONNX run spends minutes re-embedding
|
||
// identical strings. Latency numbers come from the uncached run.
|
||
func Cache(inner router.Embedder) router.Embedder {
|
||
return &cachingEmbedder{inner: inner, seen: map[string][]float32{}}
|
||
}
|
||
|
||
type cachingEmbedder struct {
|
||
inner router.Embedder
|
||
seen map[string][]float32
|
||
}
|
||
|
||
var _ router.AsymmetricEmbedder = (*cachingEmbedder)(nil)
|
||
|
||
func (c *cachingEmbedder) Dim() int { return c.inner.Dim() }
|
||
func (c *cachingEmbedder) Close() error { return nil } // the caller owns inner
|
||
|
||
func (c *cachingEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
|
||
return c.cached(ctx, "embed:"+text, func() ([]float32, error) {
|
||
return c.inner.Embed(ctx, text)
|
||
})
|
||
}
|
||
|
||
// The two sides of an asymmetric embedder give different vectors for the same
|
||
// string, so the cache key has to say which side asked.
|
||
func (c *cachingEmbedder) EmbedQuery(ctx context.Context, text string) ([]float32, error) {
|
||
return c.cached(ctx, "query:"+text, func() ([]float32, error) {
|
||
return router.EmbedQuery(ctx, c.inner, text)
|
||
})
|
||
}
|
||
|
||
func (c *cachingEmbedder) EmbedPassage(ctx context.Context, text string) ([]float32, error) {
|
||
return c.cached(ctx, "passage:"+text, func() ([]float32, error) {
|
||
return router.EmbedPassage(ctx, c.inner, text)
|
||
})
|
||
}
|
||
|
||
func (c *cachingEmbedder) cached(_ context.Context, key string, embed func() ([]float32, error)) ([]float32, error) {
|
||
if v, ok := c.seen[key]; ok {
|
||
return v, nil
|
||
}
|
||
v, err := embed()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
c.seen[key] = v
|
||
return v, nil
|
||
}
|
||
|
||
// Outcome — one scored case.
|
||
type Outcome struct {
|
||
Case Case
|
||
Hits []memory.Result
|
||
Err error
|
||
// Latency is the read path only: embed the query, then Search. Insert time
|
||
// is excluded because it happens once, weeks earlier.
|
||
Latency time.Duration
|
||
// Rank1/Rank3 — the wanted note came back first / in the top three,
|
||
// ignoring the confidence gate. Ranking is the store's job.
|
||
Rank1 bool
|
||
Rank3 bool
|
||
// Recalled — what the daemon would actually say back: the top hit's text
|
||
// when it clears the gate. Mirrors bestRecall in cmd/mavend/recall.go.
|
||
Recalled string
|
||
// Pass — the wanted note was recalled AND survived the gate; or, for a
|
||
// no-answer case, nothing was recalled.
|
||
Pass bool
|
||
// Tied — the wanted note is on top but shares its score with the next hit,
|
||
// so the sort decided it, not the embedder. Counted apart from a real hit.
|
||
Tied bool
|
||
TopID string
|
||
TopScor float64
|
||
// Margin — top1 − top2. 0 when fewer than two hits came back.
|
||
Margin float64
|
||
Reasons []string
|
||
}
|
||
|
||
// Report — the aggregate. Rank and gate are kept apart on purpose: a note that
|
||
// ranks first but is silenced by query_min_score is a threshold problem, and a
|
||
// note that never ranks first is an embedder problem. Those are different fixes.
|
||
type Report struct {
|
||
Name string
|
||
MinScore float64
|
||
// MinMargin — how far the top hit must beat the runner-up. 0 ⇒ off.
|
||
MinMargin float64
|
||
Total int
|
||
Answerable int
|
||
Rank1 int
|
||
Rank3 int
|
||
// Gated — ranked first but the score was under MinScore, so the daemon
|
||
// stays silent and answers "не знаю".
|
||
Gated int
|
||
// WrongTop — a different note outranked the right one.
|
||
WrongTop int
|
||
// Tied — the right note was on top only because of sort order. Not credited
|
||
// as recall; tracked because it is a distinct failure (the embedder scored
|
||
// the query and the note the same as everything else).
|
||
Tied int
|
||
// NoAnswer / FalseRecall — the cases that must recall nothing, and how many
|
||
// of them the daemon would answer anyway.
|
||
NoAnswer int
|
||
FalseRecall int
|
||
Errors int
|
||
Passed int
|
||
Outcomes []Outcome
|
||
ByTag map[string]TagStat
|
||
ByLang map[string]TagStat
|
||
// CorrectTop / NoAnswerTop — sorted top-1 scores for the answerable cases
|
||
// where the right note ranked first, and for the no-answer cases. The gap
|
||
// between these two distributions is what a defensible query_min_score
|
||
// would have to sit inside; if they overlap, no threshold separates them.
|
||
CorrectTop []float64
|
||
NoAnswerTop []float64
|
||
// CorrectMargin / NoAnswerMargin — the same two groups, but top1 − top2
|
||
// instead of top1. This is the pair the margin gate has to separate, and
|
||
// unlike the absolute scores it is what the sweep reads.
|
||
CorrectMargin []float64
|
||
NoAnswerMargin []float64
|
||
P50, P95, Max time.Duration
|
||
}
|
||
|
||
// TagStat — passed/total for one slice of the fixture.
|
||
type TagStat struct{ Passed, Total int }
|
||
|
||
// Recall1 — fraction of answerable cases whose wanted note ranked first.
|
||
func (r Report) Recall1() float64 { return ratio(r.Rank1, r.Answerable) }
|
||
|
||
// Recall3 — same, in the top three. The daemon asks for 3 (voice.go), so this
|
||
// is the ceiling a better gate or a reranker could reach.
|
||
func (r Report) Recall3() float64 { return ratio(r.Rank3, r.Answerable) }
|
||
|
||
// Answered — fraction of answerable cases the daemon would actually answer
|
||
// correctly, gate included. This is the number the operator experiences.
|
||
func (r Report) Answered() float64 { return ratio(r.Rank1-r.Gated, r.Answerable) }
|
||
|
||
// FalseRecallRate — fraction of the no-answer cases the daemon answers anyway.
|
||
func (r Report) FalseRecallRate() float64 { return ratio(r.FalseRecall, r.NoAnswer) }
|
||
|
||
func ratio(n, d int) float64 {
|
||
if d == 0 {
|
||
return 0
|
||
}
|
||
return float64(n) / float64(d)
|
||
}
|
||
|
||
// Score runs every case against a fresh store and aggregates. It never fails
|
||
// the run on an embed or search error: an erroring case scores as a miss and is
|
||
// counted in Errors, because "the embedder was down" and "the embedder was
|
||
// wrong" are different numbers.
|
||
func Score(ctx context.Context, name string, emb router.Embedder, newStore NewStore, minScore, minMargin float64, f Fixture) (Report, error) {
|
||
rep := Report{
|
||
Name: name,
|
||
MinScore: minScore,
|
||
MinMargin: minMargin,
|
||
Total: len(f.Cases),
|
||
ByTag: map[string]TagStat{},
|
||
ByLang: map[string]TagStat{},
|
||
}
|
||
lat := make([]time.Duration, 0, len(f.Cases))
|
||
|
||
for _, c := range f.Cases {
|
||
if c.Answerable() {
|
||
rep.Answerable++
|
||
} else {
|
||
rep.NoAnswer++
|
||
}
|
||
o, err := scoreCase(ctx, emb, newStore, minScore, minMargin, c, f.Filler)
|
||
if err != nil {
|
||
return Report{}, err
|
||
}
|
||
lat = append(lat, o.Latency)
|
||
|
||
switch {
|
||
case o.Err != nil:
|
||
rep.Errors++
|
||
case c.Answerable():
|
||
if o.Rank1 {
|
||
rep.Rank1++
|
||
}
|
||
if o.Rank3 {
|
||
rep.Rank3++
|
||
}
|
||
if o.Rank1 && o.Recalled == "" {
|
||
rep.Gated++
|
||
}
|
||
if o.Tied {
|
||
rep.Tied++
|
||
} else if !o.Rank1 {
|
||
rep.WrongTop++
|
||
}
|
||
if o.Rank1 {
|
||
// Margins are collected on rank, not on the gate, so the
|
||
// distribution does not move as the sweep changes the gate.
|
||
rep.CorrectMargin = append(rep.CorrectMargin, o.Margin)
|
||
}
|
||
if o.Rank1 && o.Recalled != "" {
|
||
rep.CorrectTop = append(rep.CorrectTop, o.TopScor)
|
||
}
|
||
default:
|
||
if o.Recalled != "" {
|
||
rep.FalseRecall++
|
||
}
|
||
rep.NoAnswerTop = append(rep.NoAnswerTop, o.TopScor)
|
||
rep.NoAnswerMargin = append(rep.NoAnswerMargin, o.Margin)
|
||
}
|
||
|
||
if o.Pass {
|
||
rep.Passed++
|
||
}
|
||
bump(rep.ByLang, c.Lang, o.Pass)
|
||
for _, tag := range c.Tags {
|
||
bump(rep.ByTag, tag, o.Pass)
|
||
}
|
||
rep.Outcomes = append(rep.Outcomes, o)
|
||
}
|
||
|
||
sort.Float64s(rep.CorrectTop)
|
||
sort.Float64s(rep.NoAnswerTop)
|
||
sort.Float64s(rep.CorrectMargin)
|
||
sort.Float64s(rep.NoAnswerMargin)
|
||
sort.Slice(lat, func(i, j int) bool { return lat[i] < lat[j] })
|
||
rep.P50, rep.P95 = percentile(lat, 0.50), percentile(lat, 0.95)
|
||
if len(lat) > 0 {
|
||
rep.Max = lat[len(lat)-1]
|
||
}
|
||
return rep, nil
|
||
}
|
||
|
||
// scoreCase inserts the case's notes into a fresh store, then runs the read
|
||
// path the daemon runs. The returned error is fatal (the harness is broken);
|
||
// an embedder or store failure on the query lands in Outcome.Err instead.
|
||
func scoreCase(ctx context.Context, emb router.Embedder, newStore NewStore, minScore, minMargin float64, c Case, filler []StoredNote) (Outcome, error) {
|
||
st, release, err := newStore()
|
||
if err != nil {
|
||
return Outcome{}, fmt.Errorf("%s: new store: %w", c.ID, err)
|
||
}
|
||
defer release()
|
||
|
||
all := append(append([]StoredNote(nil), c.Notes...), filler...)
|
||
for _, n := range all {
|
||
vec, err := router.EmbedPassage(ctx, emb, n.Text)
|
||
if err != nil {
|
||
return Outcome{}, fmt.Errorf("%s: embed note %s: %w", c.ID, n.ID, err)
|
||
}
|
||
meta := map[string]string{"text": n.Text, "type": n.Kind}
|
||
if err := st.Insert(ctx, n.ID, vec, meta); err != nil {
|
||
return Outcome{}, fmt.Errorf("%s: insert %s: %w", c.ID, n.ID, err)
|
||
}
|
||
}
|
||
|
||
o := Outcome{Case: c}
|
||
start := time.Now()
|
||
qvec, err := router.EmbedQuery(ctx, emb, c.Query)
|
||
if err != nil {
|
||
o.Latency = time.Since(start)
|
||
o.Err = err
|
||
o.Reasons = []string{fmt.Sprintf("embed query: %v", err)}
|
||
return o, nil
|
||
}
|
||
hits, err := st.Search(ctx, qvec, 3)
|
||
o.Latency = time.Since(start)
|
||
if err != nil {
|
||
o.Err = err
|
||
o.Reasons = []string{fmt.Sprintf("search: %v", err)}
|
||
return o, nil
|
||
}
|
||
o.Hits = hits
|
||
|
||
if len(hits) > 0 {
|
||
o.TopID, o.TopScor = hits[0].ID, hits[0].Score
|
||
if len(hits) > 1 {
|
||
o.Margin = hits[0].Score - hits[1].Score
|
||
}
|
||
o.Recalled = bestRecall(hits, minScore, minMargin)
|
||
}
|
||
for i, h := range hits {
|
||
if h.ID != c.Want {
|
||
continue
|
||
}
|
||
o.Rank3 = true
|
||
// A tie is not a hit. With a lexical embedder several notes score
|
||
// exactly 0 against a paraphrased query, and whichever one the sort
|
||
// happens to leave on top would otherwise be credited as recall.
|
||
if i == 0 && (len(hits) < 2 || hits[0].Score > hits[1].Score) {
|
||
o.Rank1 = true
|
||
}
|
||
if i == 0 && !o.Rank1 {
|
||
o.Tied = true
|
||
}
|
||
}
|
||
|
||
switch {
|
||
case !c.Answerable():
|
||
if o.Recalled != "" {
|
||
o.Reasons = append(o.Reasons, fmt.Sprintf("false recall: %q at %.3f (margin %.3f), want silence", o.TopID, o.TopScor, o.Margin))
|
||
}
|
||
case o.Tied:
|
||
o.Reasons = append(o.Reasons, fmt.Sprintf("tie at %.3f — the right note is on top only by sort order", o.TopScor))
|
||
case !o.Rank1:
|
||
o.Reasons = append(o.Reasons, fmt.Sprintf("top hit %q (%.3f), want %q%s", o.TopID, o.TopScor, c.Want, rankNote(o.Rank3)))
|
||
case o.Recalled == "":
|
||
o.Reasons = append(o.Reasons, fmt.Sprintf("right note ranked first at %.3f (margin %.3f) but the gate silenced it — daemon says \"не знаю\"", o.TopScor, o.Margin))
|
||
}
|
||
o.Pass = len(o.Reasons) == 0
|
||
return o, nil
|
||
}
|
||
|
||
func rankNote(inTop3 bool) string {
|
||
if inTop3 {
|
||
return " (wanted note is in the top 3)"
|
||
}
|
||
return " (wanted note is not in the top 3)"
|
||
}
|
||
|
||
// bestRecall mirrors cmd/mavend/recall.go — the gate the daemon actually
|
||
// applies to a memory hit. Duplicated rather than imported because package main
|
||
// 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 {
|
||
if !memory.Confident(results, minScore, minMargin) {
|
||
return ""
|
||
}
|
||
return results[0].Meta["text"]
|
||
}
|
||
|
||
func bump(m map[string]TagStat, key string, pass bool) {
|
||
if key == "" {
|
||
return
|
||
}
|
||
s := m[key]
|
||
s.Total++
|
||
if pass {
|
||
s.Passed++
|
||
}
|
||
m[key] = s
|
||
}
|
||
|
||
// percentile — nearest-rank on a pre-sorted slice. No interpolation: with ~30
|
||
// samples an interpolated p95 invents a latency no query actually took.
|
||
func percentile(sorted []time.Duration, p float64) time.Duration {
|
||
if len(sorted) == 0 {
|
||
return 0
|
||
}
|
||
i := int(p * float64(len(sorted)))
|
||
if i >= len(sorted) {
|
||
i = len(sorted) - 1
|
||
}
|
||
return sorted[i]
|
||
}
|
||
|
||
// String renders the report in the routing eval's style — headline first, then
|
||
// the slices that name where the path is weak.
|
||
func (r Report) String() string {
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "%s: %d/%d cases pass (gate %.2f, margin %.3f)\n", r.Name, r.Passed, r.Total, r.MinScore, r.MinMargin)
|
||
fmt.Fprintf(&b, " recall@1 %.1f%% (%d/%d) recall@3 %.1f%% (%d/%d) answered after gate %.1f%% (%d/%d)\n",
|
||
100*r.Recall1(), r.Rank1, r.Answerable,
|
||
100*r.Recall3(), r.Rank3, r.Answerable,
|
||
100*r.Answered(), r.Rank1-r.Gated, r.Answerable)
|
||
fmt.Fprintf(&b, " wrong note on top: %d | tie on top (sort order, not recall): %d | silenced by gate: %d | errors: %d\n",
|
||
r.WrongTop, r.Tied, r.Gated, r.Errors)
|
||
fmt.Fprintf(&b, " false recall %.1f%% (%d/%d must-be-silent cases answered anyway)\n",
|
||
100*r.FalseRecallRate(), r.FalseRecall, r.NoAnswer)
|
||
fmt.Fprintf(&b, " top-1 score, right note first: %s\n", spread(r.CorrectTop))
|
||
fmt.Fprintf(&b, " top-1 score, must be silent: %s\n", spread(r.NoAnswerTop))
|
||
fmt.Fprintf(&b, " margin top1-top2, right note first: %s\n", spread(r.CorrectMargin))
|
||
fmt.Fprintf(&b, " margin top1-top2, must be silent: %s\n", spread(r.NoAnswerMargin))
|
||
fmt.Fprintf(&b, " latency: p50 %s p95 %s max %s\n", r.P50, r.P95, r.Max)
|
||
fmt.Fprintf(&b, " by lang: %s\n", renderStats(r.ByLang))
|
||
fmt.Fprintf(&b, " by tag: %s\n", renderStats(r.ByTag))
|
||
return b.String()
|
||
}
|
||
|
||
// Failures — per-case detail, sorted by ID so two runs diff cleanly.
|
||
func (r Report) Failures() string {
|
||
var b strings.Builder
|
||
out := append([]Outcome(nil), r.Outcomes...)
|
||
sort.Slice(out, func(i, j int) bool { return out[i].Case.ID < out[j].Case.ID })
|
||
for _, o := range out {
|
||
if o.Pass {
|
||
continue
|
||
}
|
||
fmt.Fprintf(&b, " %s %q: %s\n", o.Case.ID, o.Case.Query, strings.Join(o.Reasons, "; "))
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// spread — min / median / max of a sorted score list. Three numbers is enough
|
||
// to see whether two distributions overlap, which is the only question a
|
||
// threshold can answer.
|
||
func spread(sorted []float64) string {
|
||
if len(sorted) == 0 {
|
||
return "n/a"
|
||
}
|
||
return fmt.Sprintf("min %.3f median %.3f max %.3f (n=%d)",
|
||
sorted[0], sorted[len(sorted)/2], sorted[len(sorted)-1], len(sorted))
|
||
}
|
||
|
||
func renderStats(m map[string]TagStat) string {
|
||
keys := make([]string, 0, len(m))
|
||
for k := range m {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
parts := make([]string, 0, len(keys))
|
||
for _, k := range keys {
|
||
s := m[k]
|
||
parts = append(parts, fmt.Sprintf("%s %d/%d", k, s.Passed, s.Total))
|
||
}
|
||
return strings.Join(parts, " ")
|
||
}
|