feat: autotag scoring overhaul, dump-based explore index, and lyrics search

Consolidates in-progress work across autotag, explore, and library:

- autotag: beets/Picard-informed scoring engine — ID-first matching, VA
  handling, recommendation tiers, and a merged distance/rank cascade, with
  an eval harness for regression tracking.
- explore: offline MusicBrainz dump import/incremental refresh replaces the
  legacy tier crawl; index-first local search with fuzzy matching and a
  dedicated ranker; disk-free guards for dump downloads.
- library: artist-credit extraction and matching.
- lyrics: owned-library lyric search (FTS) with LRCLIB backfill.

Also: rewrite README to be user-focused, and migrate upstream to
git.ljones.me/yonlu/yellowjacket.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 12:14:20 -04:00
co-authored by Claude Opus 4.8
parent d5140395da
commit 65048401e8
117 changed files with 17033 additions and 4767 deletions
+115
View File
@@ -0,0 +1,115 @@
// Package eval is the search-ranking evaluation harness. It turns
// "this query feels wrong" into a number that goes up or down, so a
// ranking change can be validated against a frozen set of labelled
// queries instead of tuned by anecdote.
//
// The harness is deliberately decoupled from the explore package: it
// knows nothing about MusicBrainz, ListenBrainz, or the search index.
// A caller adapts whatever ranking function it wants to measure to the
// Ranker interface, loads a fixture set, and runs Evaluate. The
// explore package wires its real index Search to this in an
// integration test (see explore/eval_harness_test.go).
package eval
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
)
// ErrNoFixtures is returned when a fixture file contains zero queries.
var ErrNoFixtures = errors.New("eval: fixture set is empty")
// Result is one ranked search hit, reduced to the only two fields the
// harness needs to decide whether it matches an expectation.
type Result struct {
EntityType string `json:"entityType"`
MBID string `json:"mbid"`
}
// Ranker produces an ordered result list for a query. Best result
// first. Implemented by adapting a real search function.
type Ranker interface {
Rank(query string, limit int) []Result
}
// RankerFunc adapts a plain function to the Ranker interface.
type RankerFunc func(query string, limit int) []Result
// Rank calls the underlying function.
func (f RankerFunc) Rank(query string, limit int) []Result {
return f(query, limit)
}
// Expected is one acceptable result for a fixture query. Grade is the
// graded-relevance weight used by nDCG (higher = more relevant); it
// defaults to 1 when omitted. Type is optional — when set, a ranked
// result must match both MBID and entity type to count as a hit.
type Expected struct {
Type string `json:"type,omitempty"`
MBID string `json:"mbid"`
Grade int `json:"grade,omitempty"`
}
// Fixture is a single labelled query: the input plus the result(s) a
// user should get. Every edge case ever hand-fixed in the ranker
// belongs here so it can never silently regress.
type Fixture struct {
Query string `json:"query"`
Note string `json:"note,omitempty"`
Expect []Expected `json:"expect"`
}
// LoadFixtures reads a JSON fixture file from disk.
func LoadFixtures(path string) ([]Fixture, error) {
f, err := os.Open(path) //nolint:gosec // path is a test fixture, not user input
if err != nil {
return nil, fmt.Errorf("eval: open fixtures: %w", err)
}
defer func() { _ = f.Close() }()
return ParseFixtures(f)
}
// ParseFixtures decodes a JSON fixture set from a reader.
func ParseFixtures(r io.Reader) ([]Fixture, error) {
var fixtures []Fixture
if err := json.NewDecoder(r).Decode(&fixtures); err != nil {
return nil, fmt.Errorf("eval: decode fixtures: %w", err)
}
if len(fixtures) == 0 {
return nil, ErrNoFixtures
}
return fixtures, nil
}
// matches reports whether a ranked result satisfies an expectation.
// MBID match is required; entity type is checked only when the
// expectation pins one.
func (e Expected) matches(r Result) bool {
if !strings.EqualFold(e.MBID, r.MBID) {
return false
}
if e.Type != "" && !strings.EqualFold(e.Type, r.EntityType) {
return false
}
return true
}
// grade returns the graded-relevance weight, defaulting to 1.
func (e Expected) grade() int {
if e.Grade <= 0 {
return 1
}
return e.Grade
}
+228
View File
@@ -0,0 +1,228 @@
package eval
import (
"fmt"
"math"
"sort"
"strconv"
"strings"
)
// QueryScore holds the per-query metrics for one fixture.
type QueryScore struct {
Query string
Note string
// BestRank is the 1-based rank of the highest-placed expected
// result, or 0 if none of the expected results appear in topK.
BestRank int
ReciprocalRank float64
PrecisionAtK float64
NDCGAtK float64
}
// Hit reports whether any expected result landed in topK.
func (q QueryScore) Hit() bool {
return q.BestRank > 0
}
// Report aggregates per-query scores into the numbers you watch across
// a ranking change: mean reciprocal rank, mean precision@k, mean
// nDCG@k, plus the list of queries that missed entirely.
type Report struct {
K int
NumQueries int
MRR float64
MeanPAtK float64
MeanNDCG float64
HitRate float64 // fraction of queries with any expected result in topK
Top1Rate float64 // fraction whose best expected result is rank 1
PerQuery []QueryScore
}
// Evaluate runs every fixture through the ranker and aggregates the
// results into a Report. topK bounds how deep a result can be and
// still count (a result at rank 20 helps no one).
func Evaluate(r Ranker, fixtures []Fixture, topK int) Report {
if topK <= 0 {
topK = 5
}
report := Report{K: topK, NumQueries: len(fixtures)}
for _, fx := range fixtures {
ranked := r.Rank(fx.Query, topK)
report.PerQuery = append(report.PerQuery, scoreQuery(fx, ranked, topK))
}
for _, q := range report.PerQuery {
report.MRR += q.ReciprocalRank
report.MeanPAtK += q.PrecisionAtK
report.MeanNDCG += q.NDCGAtK
if q.Hit() {
report.HitRate++
}
if q.BestRank == 1 {
report.Top1Rate++
}
}
if n := float64(len(fixtures)); n > 0 {
report.MRR /= n
report.MeanPAtK /= n
report.MeanNDCG /= n
report.HitRate /= n
report.Top1Rate /= n
}
return report
}
// scoreQuery computes the metrics for a single fixture against a ranked
// result list.
func scoreQuery(fx Fixture, ranked []Result, topK int) QueryScore {
score := QueryScore{Query: fx.Query, Note: fx.Note}
limit := min(topK, len(ranked))
relevantInK := 0
for i := range limit {
if !anyMatch(fx.Expect, ranked[i]) {
continue
}
relevantInK++
if score.BestRank == 0 {
score.BestRank = i + 1
score.ReciprocalRank = 1.0 / float64(i+1)
}
}
score.PrecisionAtK = float64(relevantInK) / float64(topK)
score.NDCGAtK = ndcg(fx.Expect, ranked, topK)
return score
}
// anyMatch reports whether a result satisfies any expectation.
func anyMatch(expected []Expected, r Result) bool {
for _, e := range expected {
if e.matches(r) {
return true
}
}
return false
}
// ndcg computes normalized discounted cumulative gain at k using graded
// relevance. Returns 0 when there are no expected results.
func ndcg(expected []Expected, ranked []Result, k int) float64 {
ideal := idealDCG(expected, k)
if ideal == 0 {
return 0
}
limit := min(k, len(ranked))
dcg := 0.0
for i := range limit {
g := matchedGrade(expected, ranked[i])
if g == 0 {
continue
}
dcg += gain(g, i)
}
return dcg / ideal
}
// matchedGrade returns the relevance grade for a result, or 0 if it
// matches no expectation.
func matchedGrade(expected []Expected, r Result) int {
for _, e := range expected {
if e.matches(r) {
return e.grade()
}
}
return 0
}
// idealDCG is the DCG of the best possible ordering: every expected
// result, sorted by grade descending, placed at the front.
func idealDCG(expected []Expected, k int) float64 {
grades := make([]int, 0, len(expected))
for _, e := range expected {
grades = append(grades, e.grade())
}
sort.Sort(sort.Reverse(sort.IntSlice(grades)))
limit := min(k, len(grades))
ideal := 0.0
for i := range limit {
ideal += gain(grades[i], i)
}
return ideal
}
// gain is the discounted gain of a grade at 0-based position i.
func gain(grade, i int) float64 {
return (math.Pow(2, float64(grade)) - 1) / math.Log2(float64(i+2))
}
// Format renders a Report as a human-readable table for test output.
func (r Report) Format() string {
var b strings.Builder
fmt.Fprintf(&b, "ranking eval — %d queries @k=%d\n", r.NumQueries, r.K)
fmt.Fprintf(&b, " MRR %.3f\n", r.MRR)
fmt.Fprintf(&b, " P@%d %.3f\n", r.K, r.MeanPAtK)
fmt.Fprintf(&b, " nDCG@%d %.3f\n", r.K, r.MeanNDCG)
fmt.Fprintf(&b, " hit rate %.3f\n", r.HitRate)
fmt.Fprintf(&b, " top-1 rate %.3f\n", r.Top1Rate)
misses := r.Misses()
if len(misses) > 0 {
b.WriteString(" misses:\n")
for _, m := range misses {
fmt.Fprintf(&b, " %-40q rank=%s\n", m.Query, rankLabel(m.BestRank))
}
}
return b.String()
}
// Misses returns the queries whose best expected result was absent
// from topK or buried below rank 1 — the regression watch-list.
func (r Report) Misses() []QueryScore {
var out []QueryScore
for _, q := range r.PerQuery {
if q.BestRank != 1 {
out = append(out, q)
}
}
return out
}
func rankLabel(rank int) string {
if rank == 0 {
return "absent"
}
return strconv.Itoa(rank)
}
+193
View File
@@ -0,0 +1,193 @@
package eval
import (
"math"
"strings"
"testing"
)
// rankerFromIDs builds a Ranker that returns a fixed ordering keyed by
// query, for deterministic metric tests.
func rankerFromIDs(table map[string][]Result) Ranker {
return RankerFunc(func(query string, limit int) []Result {
out := table[query]
if limit < len(out) {
out = out[:limit]
}
return out
})
}
func approx(a, b float64) bool {
return math.Abs(a-b) < 1e-9
}
func TestReciprocalRank(t *testing.T) {
tests := []struct {
name string
ranked []Result
expect []Expected
wantRR float64
wantPos int
}{
{
name: "top result",
ranked: []Result{{MBID: "a"}, {MBID: "b"}},
expect: []Expected{{MBID: "a"}},
wantRR: 1.0,
wantPos: 1,
},
{
name: "third result",
ranked: []Result{{MBID: "x"}, {MBID: "y"}, {MBID: "a"}},
expect: []Expected{{MBID: "a"}},
wantRR: 1.0 / 3.0,
wantPos: 3,
},
{
name: "absent",
ranked: []Result{{MBID: "x"}, {MBID: "y"}},
expect: []Expected{{MBID: "a"}},
wantRR: 0,
wantPos: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fx := Fixture{Query: "q", Expect: tt.expect}
got := scoreQuery(fx, tt.ranked, 5)
if !approx(got.ReciprocalRank, tt.wantRR) {
t.Errorf("RR = %v, want %v", got.ReciprocalRank, tt.wantRR)
}
if got.BestRank != tt.wantPos {
t.Errorf("BestRank = %d, want %d", got.BestRank, tt.wantPos)
}
})
}
}
func TestPrecisionAtK(t *testing.T) {
fx := Fixture{
Query: "q",
Expect: []Expected{{MBID: "a"}, {MBID: "b"}},
}
ranked := []Result{{MBID: "a"}, {MBID: "x"}, {MBID: "b"}, {MBID: "y"}}
got := scoreQuery(fx, ranked, 4)
// 2 relevant out of k=4.
if !approx(got.PrecisionAtK, 0.5) {
t.Errorf("P@4 = %v, want 0.5", got.PrecisionAtK)
}
}
func TestNDCGRespectsOrdering(t *testing.T) {
expect := []Expected{{MBID: "a", Grade: 3}, {MBID: "b", Grade: 1}}
// Ideal ordering: high-grade result first.
good := scoreQuery(
Fixture{Query: "q", Expect: expect},
[]Result{{MBID: "a"}, {MBID: "b"}, {MBID: "z"}},
5,
)
// Worse ordering: high-grade result buried below an irrelevant one.
bad := scoreQuery(
Fixture{Query: "q", Expect: expect},
[]Result{{MBID: "z"}, {MBID: "b"}, {MBID: "a"}},
5,
)
if !approx(good.NDCGAtK, 1.0) {
t.Errorf("ideal ordering nDCG = %v, want 1.0", good.NDCGAtK)
}
if bad.NDCGAtK >= good.NDCGAtK {
t.Errorf("worse ordering nDCG %v should be < ideal %v", bad.NDCGAtK, good.NDCGAtK)
}
}
func TestTypeMustMatchWhenPinned(t *testing.T) {
fx := Fixture{
Query: "q",
Expect: []Expected{{Type: "artist", MBID: "a"}},
}
// Same MBID but wrong entity type — must not count.
wrongType := scoreQuery(fx, []Result{{EntityType: "recording", MBID: "a"}}, 5)
if wrongType.Hit() {
t.Error("result with wrong entity type counted as a hit")
}
rightType := scoreQuery(fx, []Result{{EntityType: "artist", MBID: "a"}}, 5)
if !rightType.Hit() {
t.Error("result with matching entity type did not count")
}
}
func TestEvaluateAggregates(t *testing.T) {
fixtures := []Fixture{
{Query: "hit-top", Expect: []Expected{{MBID: "a"}}},
{Query: "hit-second", Expect: []Expected{{MBID: "a"}}},
{Query: "miss", Expect: []Expected{{MBID: "a"}}},
}
r := rankerFromIDs(map[string][]Result{
"hit-top": {{MBID: "a"}},
"hit-second": {{MBID: "x"}, {MBID: "a"}},
"miss": {{MBID: "x"}, {MBID: "y"}},
})
report := Evaluate(r, fixtures, 5)
// MRR = (1 + 1/2 + 0) / 3.
wantMRR := (1.0 + 0.5 + 0.0) / 3.0
if !approx(report.MRR, wantMRR) {
t.Errorf("MRR = %v, want %v", report.MRR, wantMRR)
}
// 2 of 3 queries surfaced the result somewhere in topK.
if !approx(report.HitRate, 2.0/3.0) {
t.Errorf("HitRate = %v, want %v", report.HitRate, 2.0/3.0)
}
// Only 1 of 3 had it at rank 1.
if !approx(report.Top1Rate, 1.0/3.0) {
t.Errorf("Top1Rate = %v, want %v", report.Top1Rate, 1.0/3.0)
}
if len(report.Misses()) != 2 {
t.Errorf("Misses = %d, want 2", len(report.Misses()))
}
}
func TestParseFixtures(t *testing.T) {
const doc = `[
{"query": "radiohead", "expect": [{"type": "artist", "mbid": "abc"}]},
{"query": "ok computer", "note": "album not band", "expect": [{"mbid": "def", "grade": 2}]}
]`
fixtures, err := ParseFixtures(strings.NewReader(doc))
if err != nil {
t.Fatalf("ParseFixtures: %v", err)
}
if len(fixtures) != 2 {
t.Fatalf("got %d fixtures, want 2", len(fixtures))
}
if fixtures[0].Expect[0].MBID != "abc" {
t.Errorf("MBID = %q, want abc", fixtures[0].Expect[0].MBID)
}
}
func TestParseFixturesEmpty(t *testing.T) {
_, err := ParseFixtures(strings.NewReader(`[]`))
if err == nil {
t.Fatal("expected ErrNoFixtures, got nil")
}
}
+32
View File
@@ -0,0 +1,32 @@
[
{
"query": "radiohead",
"note": "single-word artist name — should resolve to the artist, not an album titled similarly",
"expect": [{ "type": "artist", "mbid": "a74b1b7f-71a5-4011-9441-d0b5e4122711" }]
},
{
"query": "the beatles",
"note": "common-word prefix must not let the article dominate",
"expect": [{ "type": "artist", "mbid": "b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d" }]
},
{
"query": "abbey road",
"note": "album title — should rank the release group above any track of the same name",
"expect": [{ "type": "release_group", "mbid": "" }]
},
{
"query": "calling you blue october",
"note": "composite title+artist query — recording should win even if the artist is unindexed",
"expect": [{ "type": "recording", "mbid": "" }]
},
{
"query": "the teenagers",
"note": "regression: must rank The Teenagers above The Beatles despite far lower popularity",
"expect": [{ "type": "artist", "mbid": "" }]
},
{
"query": "beyonce",
"note": "diacritic folding (migration 37): unaccented query must find the accented artist Beyoncé",
"expect": [{ "type": "artist", "mbid": "859d0860-d480-4efd-970c-c05d5f1776b8" }]
}
]