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
+108
View File
@@ -0,0 +1,108 @@
// Package eval is the autotag candidate-scoring evaluation harness.
// It turns "this match feels wrong" into a number that goes up or
// down, so a scoring change can be validated against a frozen set of
// labelled cases instead of tuned by anecdote.
//
// A case describes a local album-group (the files on disk) plus a set
// of candidate releases, and pins expectations: which candidate must
// rank first, and per-candidate score floors/ceilings. The harness
// is decoupled from the scorer: a caller adapts whatever ranking
// function it wants to measure to the Ranker interface (the autotag
// package wires autotag.RankCandidates to it in eval_harness_test.go).
//
// The point of the ceiling assertions is negative testing: a known
// wrong candidate (same title, different artist) must stay BELOW a
// confidence bar, which is exactly the false-positive class the
// artist term + evidence scaling exist to suppress.
package eval
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
)
// ErrNoCases is returned when a fixture file contains zero cases.
var ErrNoCases = errors.New("eval: fixture set is empty")
// LocalTrackFixture is one on-disk track, in the shape a case author
// hand-writes. Millisecond durations match the scorer's units.
type LocalTrackFixture struct {
Title string `json:"title"`
Artist string `json:"artist,omitempty"`
Track int `json:"track,omitempty"`
Disc int `json:"disc,omitempty"`
LengthMs int64 `json:"lengthMs,omitempty"`
}
// CandidateTrackFixture is one track inside a candidate release.
type CandidateTrackFixture struct {
Pos int `json:"pos"`
Disc int `json:"disc,omitempty"`
Title string `json:"title"`
LengthMs int64 `json:"lengthMs,omitempty"`
}
// CandidateFixture is one candidate release the scorer must rank.
// Source is "local" or "musicbrainz" (default) — it drives
// evidence scaling, so it matters for singleton cases.
type CandidateFixture struct {
MBID string `json:"mbid"`
Title string `json:"title,omitempty"`
ArtistCredit string `json:"artistCredit,omitempty"`
Status string `json:"status,omitempty"`
Country string `json:"country,omitempty"`
PrimaryType string `json:"primaryType,omitempty"`
Source string `json:"source,omitempty"`
Tracks []CandidateTrackFixture `json:"tracks"`
}
// Case is a single labelled scoring scenario. Every real-world
// mismatch worth guarding against belongs here so it can never
// silently regress.
//
// - AlbumName / AlbumArtist mirror the tagging item's fields —
// leave them empty to keep the album-title / artist terms
// neutral for the case.
// - ExpectTop, when set, is the MBID that must rank first.
// - MaxScore pins per-MBID ceilings (candidate must score <= value).
// - MinScore pins per-MBID floors (candidate must score >= value).
type Case struct {
Note string `json:"note,omitempty"`
AlbumName string `json:"albumName,omitempty"`
AlbumArtist string `json:"albumArtist,omitempty"`
Local []LocalTrackFixture `json:"local"`
Candidates []CandidateFixture `json:"candidates"`
ExpectTop string `json:"expectTop,omitempty"`
MaxScore map[string]float64 `json:"maxScore,omitempty"`
MinScore map[string]float64 `json:"minScore,omitempty"`
}
// LoadCases reads a JSON fixture file from disk.
func LoadCases(path string) ([]Case, 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 cases: %w", err)
}
defer func() { _ = f.Close() }()
return ParseCases(f)
}
// ParseCases decodes a JSON case set from a reader.
func ParseCases(r io.Reader) ([]Case, error) {
var cases []Case
if err := json.NewDecoder(r).Decode(&cases); err != nil {
return nil, fmt.Errorf("eval: decode cases: %w", err)
}
if len(cases) == 0 {
return nil, ErrNoCases
}
return cases, nil
}
+123
View File
@@ -0,0 +1,123 @@
package eval
import "fmt"
// ScoredCandidate is one ranked candidate reduced to what the harness
// checks: its MBID and final score. The slice a Ranker returns must
// be ordered best-first.
type ScoredCandidate struct {
MBID string
Score float64
}
// Ranker scores and orders the candidates of a single case. Best
// candidate first. Implemented by adapting the real scorer (see
// eval_harness_test.go), which is why the eval package itself never
// imports autotag.
type Ranker interface {
Rank(c Case) []ScoredCandidate
}
// RankerFunc adapts a plain function to the Ranker interface.
type RankerFunc func(c Case) []ScoredCandidate
// Rank calls the underlying function.
func (f RankerFunc) Rank(c Case) []ScoredCandidate {
return f(c)
}
// CaseResult records how one case fared. Failures is empty when the
// case passed every pinned expectation.
type CaseResult struct {
Case Case
Failures []string
}
// Passed reports whether the case met every expectation.
func (r CaseResult) Passed() bool {
return len(r.Failures) == 0
}
// Report aggregates results across a case set.
type Report struct {
Results []CaseResult
}
// Passed counts cases that met every expectation.
func (r Report) Passed() int {
n := 0
for _, c := range r.Results {
if c.Passed() {
n++
}
}
return n
}
// Accuracy is the fraction of cases that passed, in [0, 1].
func (r Report) Accuracy() float64 {
if len(r.Results) == 0 {
return 0
}
return float64(r.Passed()) / float64(len(r.Results))
}
// Evaluate runs every case through the ranker and checks its pinned
// expectations (ExpectTop, MaxScore ceilings, MinScore floors),
// returning a Report the caller can assert on and print.
func Evaluate(cases []Case, ranker Ranker) Report {
rep := Report{Results: make([]CaseResult, 0, len(cases))}
for _, c := range cases {
rep.Results = append(rep.Results, evaluateCase(c, ranker))
}
return rep
}
func evaluateCase(c Case, ranker Ranker) CaseResult {
ranked := ranker.Rank(c)
res := CaseResult{Case: c}
byMBID := make(map[string]float64, len(ranked))
for _, r := range ranked {
byMBID[r.MBID] = r.Score
}
if c.ExpectTop != "" {
switch {
case len(ranked) == 0:
res.Failures = append(
res.Failures,
"expected top "+c.ExpectTop+" but ranking was empty",
)
case ranked[0].MBID != c.ExpectTop:
res.Failures = append(res.Failures, fmt.Sprintf(
"top = %q (%.3f), want %q (%.3f)",
ranked[0].MBID, ranked[0].Score, c.ExpectTop, byMBID[c.ExpectTop],
))
}
}
for mbid, ceil := range c.MaxScore {
if got, ok := byMBID[mbid]; ok && got > ceil {
res.Failures = append(res.Failures, fmt.Sprintf(
"%s scored %.3f, want <= %.3f", mbid, got, ceil,
))
}
}
for mbid, floor := range c.MinScore {
if got, ok := byMBID[mbid]; ok && got < floor {
res.Failures = append(res.Failures, fmt.Sprintf(
"%s scored %.3f, want >= %.3f", mbid, got, floor,
))
}
}
return res
}
+225
View File
@@ -0,0 +1,225 @@
[
{
"note": "seed: single with a generic title must reject a same-title, different-artist, wrong-length MB hit and prefer the correct artist. This is the reported 92%-false-positive class.",
"local": [
{ "title": "Intro", "artist": "Real Artist", "track": 1, "lengthMs": 90000 }
],
"candidates": [
{
"mbid": "right",
"artistCredit": "Real Artist",
"status": "Official",
"source": "musicbrainz",
"tracks": [{ "pos": 1, "title": "Intro", "lengthMs": 90000 }]
},
{
"mbid": "wrong-artist",
"artistCredit": "Some Other Band",
"status": "Official",
"source": "musicbrainz",
"tracks": [{ "pos": 1, "title": "Intro", "lengthMs": 240000 }]
}
],
"expectTop": "right",
"maxScore": { "wrong-artist": 0.75 }
},
{
"note": "seed: full-album exact match should still read as a confident, near-perfect match (evidence scaling must not punish real albums).",
"local": [
{ "title": "Song A", "artist": "The Band", "track": 1, "lengthMs": 200000 },
{ "title": "Song B", "artist": "The Band", "track": 2, "lengthMs": 210000 },
{ "title": "Song C", "artist": "The Band", "track": 3, "lengthMs": 195000 },
{ "title": "Song D", "artist": "The Band", "track": 4, "lengthMs": 220000 }
],
"candidates": [
{
"mbid": "album",
"artistCredit": "The Band",
"status": "Official",
"source": "musicbrainz",
"tracks": [
{ "pos": 1, "title": "Song A", "lengthMs": 200000 },
{ "pos": 2, "title": "Song B", "lengthMs": 210000 },
{ "pos": 3, "title": "Song C", "lengthMs": 195000 },
{ "pos": 4, "title": "Song D", "lengthMs": 220000 }
]
}
],
"expectTop": "album",
"minScore": { "album": 0.9 }
},
{
"note": "seed: near-right artist (accent/spelling) must stay a strong match; soft artist term, not a gate.",
"local": [
{ "title": "Jolene", "artist": "Beyonce", "track": 1, "lengthMs": 200000 },
{ "title": "Halo", "artist": "Beyonce", "track": 2, "lengthMs": 220000 }
],
"candidates": [
{
"mbid": "accented",
"artistCredit": "Beyoncé",
"status": "Official",
"source": "musicbrainz",
"tracks": [
{ "pos": 1, "title": "Jolene", "lengthMs": 200000 },
{ "pos": 2, "title": "Halo", "lengthMs": 220000 }
]
}
],
"expectTop": "accented",
"minScore": { "accented": 0.85 }
},
{
"note": "VA compilation: per-track artists all differ, album-artist says Various Artists. The VA candidate must not be penalised for its artist credit and must read as a strong match.",
"albumName": "Now That's What I Call Music! 60",
"albumArtist": "Various Artists",
"local": [
{ "title": "Song One", "artist": "Artist A", "track": 1, "lengthMs": 200000 },
{ "title": "Song Two", "artist": "Artist B", "track": 2, "lengthMs": 210000 },
{ "title": "Song Three", "artist": "Artist C", "track": 3, "lengthMs": 195000 },
{ "title": "Song Four", "artist": "Artist D", "track": 4, "lengthMs": 205000 }
],
"candidates": [
{
"mbid": "va-comp",
"title": "Now That's What I Call Music! 60",
"artistCredit": "Various Artists",
"status": "Official",
"primaryType": "Compilation",
"source": "musicbrainz",
"tracks": [
{ "pos": 1, "title": "Song One", "lengthMs": 200000 },
{ "pos": 2, "title": "Song Two", "lengthMs": 210000 },
{ "pos": 3, "title": "Song Three", "lengthMs": 195000 },
{ "pos": 4, "title": "Song Four", "lengthMs": 205000 }
]
}
],
"expectTop": "va-comp",
"minScore": { "va-comp": 0.85 }
},
{
"note": "same recordings, two release groups: folder album name must pull the studio album above the greatest-hits comp with an identical tracklist.",
"albumName": "The Studio Album",
"albumArtist": "The Band",
"local": [
{ "title": "Song A", "artist": "The Band", "track": 1, "lengthMs": 200000 },
{ "title": "Song B", "artist": "The Band", "track": 2, "lengthMs": 210000 },
{ "title": "Song C", "artist": "The Band", "track": 3, "lengthMs": 195000 }
],
"candidates": [
{
"mbid": "studio",
"title": "The Studio Album",
"artistCredit": "The Band",
"status": "Official",
"primaryType": "Album",
"source": "musicbrainz",
"tracks": [
{ "pos": 1, "title": "Song A", "lengthMs": 200000 },
{ "pos": 2, "title": "Song B", "lengthMs": 210000 },
{ "pos": 3, "title": "Song C", "lengthMs": 195000 }
]
},
{
"mbid": "hits-comp",
"title": "Greatest Hits",
"artistCredit": "The Band",
"status": "Official",
"primaryType": "Compilation",
"source": "musicbrainz",
"tracks": [
{ "pos": 1, "title": "Song A", "lengthMs": 200000 },
{ "pos": 2, "title": "Song B", "lengthMs": 210000 },
{ "pos": 3, "title": "Song C", "lengthMs": 195000 }
]
}
],
"expectTop": "studio",
"minScore": { "studio": 0.9 }
},
{
"note": "disc 1 of a 2-disc release: per-disc group must not be punished for 'missing' disc 2, and must not lose to a random single-disc release with fewer matching tracks.",
"albumName": "The Double Album",
"albumArtist": "The Band",
"local": [
{ "title": "D1 Track 1", "artist": "The Band", "track": 1, "disc": 1, "lengthMs": 200000 },
{ "title": "D1 Track 2", "artist": "The Band", "track": 2, "disc": 1, "lengthMs": 210000 },
{ "title": "D1 Track 3", "artist": "The Band", "track": 3, "disc": 1, "lengthMs": 195000 }
],
"candidates": [
{
"mbid": "double",
"title": "The Double Album",
"artistCredit": "The Band",
"status": "Official",
"primaryType": "Album",
"source": "musicbrainz",
"tracks": [
{ "pos": 1, "disc": 1, "title": "D1 Track 1", "lengthMs": 200000 },
{ "pos": 2, "disc": 1, "title": "D1 Track 2", "lengthMs": 210000 },
{ "pos": 3, "disc": 1, "title": "D1 Track 3", "lengthMs": 195000 },
{ "pos": 1, "disc": 2, "title": "D2 Track 1", "lengthMs": 220000 },
{ "pos": 2, "disc": 2, "title": "D2 Track 2", "lengthMs": 230000 },
{ "pos": 3, "disc": 2, "title": "D2 Track 3", "lengthMs": 240000 }
]
}
],
"expectTop": "double",
"minScore": { "double": 0.9 }
},
{
"note": "streaming-style dash qualifiers: '- 2011 Remaster' suffixes on every local title must not cost title score against the clean MB tracklist.",
"albumName": "Classic Album",
"albumArtist": "The Band",
"local": [
{ "title": "Opener - 2011 Remaster", "artist": "The Band", "track": 1, "lengthMs": 200000 },
{ "title": "Middle Cut - 2011 Remaster", "artist": "The Band", "track": 2, "lengthMs": 210000 },
{ "title": "Closer - 2011 Remaster", "artist": "The Band", "track": 3, "lengthMs": 195000 }
],
"candidates": [
{
"mbid": "clean-titles",
"title": "Classic Album",
"artistCredit": "The Band",
"status": "Official",
"primaryType": "Album",
"source": "musicbrainz",
"tracks": [
{ "pos": 1, "title": "Opener", "lengthMs": 200000 },
{ "pos": 2, "title": "Middle Cut", "lengthMs": 210000 },
{ "pos": 3, "title": "Closer", "lengthMs": 195000 }
]
}
],
"expectTop": "clean-titles",
"minScore": { "clean-titles": 0.9 }
},
{
"note": "sorted-artist tag: 'Beatles, The' must match 'The Beatles' releases at full strength (article rotation).",
"albumName": "Abbey Road",
"albumArtist": "Beatles, The",
"local": [
{ "title": "Come Together", "artist": "Beatles, The", "track": 1, "lengthMs": 259000 },
{ "title": "Something", "artist": "Beatles, The", "track": 2, "lengthMs": 182000 },
{ "title": "Octopus's Garden", "artist": "Beatles, The", "track": 3, "lengthMs": 170000 }
],
"candidates": [
{
"mbid": "abbey",
"title": "Abbey Road",
"artistCredit": "The Beatles",
"status": "Official",
"primaryType": "Album",
"source": "musicbrainz",
"tracks": [
{ "pos": 1, "title": "Come Together", "lengthMs": 259000 },
{ "pos": 2, "title": "Something", "lengthMs": 182000 },
{ "pos": 3, "title": "Octopus's Garden", "lengthMs": 170000 }
]
}
],
"expectTop": "abbey",
"minScore": { "abbey": 0.9 }
}
]