f6d5a2a7a4
The old model was a symmetric paraphrase model, so it scored "do these look alike" instead of "does this note answer this question". Also fixes the file mismatch: the Makefile, the deploy config and both evals now all name the same quantized file, and the quantized one is what gets measured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
495 lines
17 KiB
Go
495 lines
17 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
|
|
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
|
|
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
|
|
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 float64, f Fixture) (Report, error) {
|
|
rep := Report{
|
|
Name: name,
|
|
MinScore: minScore,
|
|
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, 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 && o.Recalled != "" {
|
|
rep.CorrectTop = append(rep.CorrectTop, o.TopScor)
|
|
}
|
|
default:
|
|
if o.Recalled != "" {
|
|
rep.FalseRecall++
|
|
}
|
|
rep.NoAnswerTop = append(rep.NoAnswerTop, o.TopScor)
|
|
}
|
|
|
|
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.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 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
|
|
o.Recalled = bestRecall(hits, minScore)
|
|
}
|
|
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, want silence", o.TopID, o.TopScor))
|
|
}
|
|
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 but scored %.3f < gate %.2f — daemon says \"не знаю\"", o.TopScor, minScore))
|
|
}
|
|
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.
|
|
func bestRecall(results []memory.Result, min float64) string {
|
|
if len(results) == 0 || results[0].Score < min {
|
|
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)\n", r.Name, r.Passed, r.Total, r.MinScore)
|
|
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, " 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, " ")
|
|
}
|