Fix track-matching correctness (rune/byte) and per-tick performance (#330) - #357
Fix track-matching correctness (rune/byte) and per-tick performance (#330)#357joestump wants to merge 2 commits into
Conversation
Implements #330. - similarity() now measures both Levenshtein distance and max length in runes; the byte-based max length inflated similarity 2-3x for non-ASCII (CJK/Cyrillic) titles, letting wrong tracks clear the fuzzy threshold (SPEC track-matching REQ-TM-031, ADR-0014) - New exported LibraryIndex/NormalizedCandidate: the user's library is loaded once and each candidate's title/artist is normalized once, instead of re-running normalizeForMatch per candidate per source track; reusable by other fuzzy-match consumers (story #340) - SyncAllEnabledPlaylists loads the library index once per sync tick and shares it across playlists; MatchTracks keeps its old signature for single-playlist callers - playlist_sync.min_match_confidence default aligned to 0.7 per ADR-0014 and REQ-PLSYNC-003 (was 0.8 with no recorded rationale) - Tests: rune-unit similarity/levenshtein cases, CJK/Cyrillic no-false-match regressions, suffix-variant matches, query-counting proof that per-tick library queries no longer scale with playlist count, plus shared-index vs legacy benchmarks Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial review — PR #357 (issue #330)Verdict: APPROVE — no blocking defects found. The rune/byte fix, the normalization index, the per-tick library load, and the 0.7 alignment are all correct and well-tested. Findings below are non-blocking. What was verified adversarially (independent checkout,
|
- SyncAllEnabledPlaylists: a tick-level LoadLibraryIndex failure no longer aborts the tick before any playlist gets error handling; it falls back to per-playlist loading so each playlist still reaches handleSyncError (sync_status=error, SyncEvent, UI notification per REQ-PLSYNC-060) - similarity(): restore identical-input fast path via slices.Equal - MatchTracksWithIndex: drop unused ctx param (no I/O) and add a defensive nil-index panic with a clear message - LoadLibraryIndex: remove redundant NavidromeID nil check (query already filters NavidromeIDNotNil) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…test Non-blocking review items from the adversarial review of the joestump#357 port: - MatchTracksWithIndex: document the non-nil index INVARIANT in the doc comment (callers must guard, as syncPlaylistToNavidrome does); an unguarded nil from a future background-goroutine caller would panic and crash the server. Signature unchanged. - Add TestPlaylistSyncService_SyncAllEnabledPlaylists_LibraryIndexLoadFailure: injects a driver that fails only "tracks"-table queries so the tick-level LoadLibraryIndex in SyncAllEnabledPlaylists fails, then asserts the tick does not abort — each due playlist falls back to a per-playlist load (query-count proof: 1 tick + 1 per playlist) and still reaches handleSyncError with sync_status=error and a playlist_sync_failed SyncEvent per playlist (REQ-PLSYNC-060). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ck library index (#32) * Port upstream PR joestump#357: rune-based track matching and shared LibraryIndex Ports joestump#357 (commits b04c2df + e4d4ed7, review polish included), implementing joestump#330. - similarity() now measures both Levenshtein distance and max length in RUNES; the fork's version computed a rune-based distance but divided by a byte-based max length, inflating similarity 2-3x for non-ASCII (CJK/Cyrillic) titles and letting wrong tracks clear the fuzzy threshold (SPEC track-matching REQ-TM-031, ADR-0014) - New exported LibraryIndex/NormalizedCandidate: the user's library is loaded and normalized once per sync tick via LoadLibraryIndex and shared across playlists via MatchTracksWithIndex; MatchTracks keeps its old signature for single-playlist callers - SyncAllEnabledPlaylists loads the index once per tick; a tick-level load failure falls back to per-playlist loading so each playlist still reaches handleSyncError (sync_status=error, SyncEvent, UI notification per REQ-PLSYNC-060) - Preserves the fork's regex-based suffix normalization, per-strategy metric.track_match metrics (ADR-0019), and artist-edge user scoping - Config default playlist_sync.min_match_confidence was already 0.7 in the fork; upstream's config change reduced to a comment no-op - Tests: rune-unit similarity/levenshtein cases, CJK/Cyrillic no-false-match regressions, suffix-variant matches, query-counting proof that per-tick library queries no longer scale with playlist count, shared-index vs legacy benchmarks Known follow-up (out of scope, upstream deferred to story joestump#340): the same rune/byte mismatch exists in internal/vibes/generator.go similarity(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address PR #32 review: nil-index invariant doc + tick-level fallback test Non-blocking review items from the adversarial review of the joestump#357 port: - MatchTracksWithIndex: document the non-nil index INVARIANT in the doc comment (callers must guard, as syncPlaylistToNavidrome does); an unguarded nil from a future background-goroutine caller would panic and crash the server. Signature unchanged. - Add TestPlaylistSyncService_SyncAllEnabledPlaylists_LibraryIndexLoadFailure: injects a driver that fails only "tracks"-table queries so the tick-level LoadLibraryIndex in SyncAllEnabledPlaylists fails, then asserts the tick does not abort — each due playlist falls back to a per-playlist load (query-count proof: 1 tick + 1 per playlist) and still reaches handleSyncError with sync_status=error and a playlist_sync_failed SyncEvent per playlist (REQ-PLSYNC-060). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of epic #317. Governing: ADR-0014 (three-tier track matching), SPEC track-matching REQ-TM-031/REQ-TM-041, SPEC playlist-sync-navidrome REQ-PLSYNC-003.
What / Why
1. Rune/byte similarity mismatch (correctness bug)
similarity()divided a rune-based Levenshtein distance by a byte-basedlen(). For non-ASCII titles (CJK = 3 bytes/rune, Cyrillic = 2 bytes/rune) this inflated similarity 2-3x, so entirely dissimilar titles cleared the fuzzy threshold and wrong tracks were written into synced Navidrome playlists. Example: library "夜に駆ける" vs source "群青" by the same artist scored0.6*(1-5/15) + 0.4*1.0 = 0.8(match!) instead of0.6*0.0 + 0.4*1.0 = 0.4(no match).similarity()andlevenshtein()now operate on[]runethroughout — distance and max length are both in rune units.2. Per-candidate re-normalization (O(sources x library))
findBestFuzzyMatchre-rannormalizeForMatch(~30 TrimSuffix passes + rune scan) for every candidate for every unmatched source track. Normalization now happens once per library track when building the index.3. Library reloaded per playlist (O(playlists x library) DB cost)
SyncAllEnabledPlaylists->SyncPlaylistToNavidrome->MatchTracksre-ran the full unbounded library query per playlist. New exportedTrackMatcher.LoadLibraryIndex(ctx, userID)builds aLibraryIndex(ISRC map, exact-match map, normalized fuzzy candidates) once;MatchTracksWithIndexmatches against it with zero DB queries.SyncAllEnabledPlaylistsbuilds the index once per tick and shares it across all playlists.MatchTrackskeeps its old signature (load + match) for single-playlist callers (manual sync, pair, rebuild).Design note for #340:
LibraryIndex/NormalizedCandidateare exported precisely so the vibes fuzzy matcher can unify onto this stack instead of keeping its duplicate implementation — the cache is a reusable type, not buried in one function.4. Confidence default aligned to 0.7
ADR-0014 and REQ-PLSYNC-003 both say default 0.7;
internal/config/config.gosetplaylist_sync.min_match_confidenceto 0.8. Git history shows 0.8 came from the original "Got syncing to Navidrome working" bootstrap commit (db9844d) with no recorded rationale, while the ADR's 0.7 is a deliberate, documented decision. Decision: config now defaults to 0.7 (the ADR value), with a governing comment. The stale vibes comment ("Lower than playlist sync") was updated since the two defaults are now equal.Test evidence
TestSimilarity_CJK_DissimilarTitlesScoreZero/TestSimilarity_Cyrillic_DissimilarTitlesScoreLow— dissimilar CJK titles now score 0.0 (previously ~0.67 via byte inflation)TestTrackMatcher_CJK_DissimilarTitlesDoNotMatch/TestTrackMatcher_Cyrillic_DissimilarTitlesDoNotMatch— end-to-end regression: same-artist dissimilar non-ASCII titles reportMatchMethod=noneat the 0.7 threshold (old code matched them)TestFindBestFuzzyMatch_SuffixVariantsStillMatch+ existingTestTrackMatcher_FuzzyMatch_Remastered/_RadioEdit— remaster/radio-edit/live suffix variants still matchTestSimilarity_ASCII_UnchangedByRuneFix— ASCII scoring is bit-identical to the old behaviorTestLibraryIndex_LoadedOncePerTick, via a countingdialect.Driver): matching 5 playlists with the shared index issues exactly 1 library load (2 SQL queries: tracks + eager artist edge) and zero per-playlist queries; the legacy path issues 5x that. Per-tick DB cost no longer scales with playlist count x library size.BenchmarkMatchTracksSharedIndex21.5ms/op vsBenchmarkMatchTracksLegacy25.3ms/op — and the shared-index path amortizes its one-time index build across all playlists in the tick, while legacy pays the query + re-normalization on every playlist.make testgreen;gofmt -l .clean;go vetclean. (make lint-gofails identically onorigin/main— local golangci-lint v2 binary rejects the repo's v1 config; pre-existing, unrelated.)Deferred Design Doc Updates
Spec paths are protected; the following governing-doc edits are deferred to a docs-owner pass:
docs/openspec/specs/track-matching/spec.mdREQ-TM-031: pin rune units for both the Levenshtein distance and the max string length (currently says1.0 - (levenshtein_distance / max_string_length)without units — too imprecise to catch this bug class)docs/openspec/specs/track-matching/spec.mdconfig reference: documentplaylist_sync.min_match_confidencedefault 0.7 (per ADR-0014 / REQ-PLSYNC-003), now aligned ininternal/config/config.godocs/adrs/ADR-0014-three-tier-track-matching-algorithm.md: "More Information" section referencesfindBestFuzzyMatch()iterating raw*ent.Trackcandidates; could be refreshed to mentionLibraryIndex/NormalizedCandidateand per-tick index reuseCloses #330
🤖 Posted on behalf of
@joestumpby Claude.