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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user