Fix #769: playlist sync matched wrong same-artist track with high confidence

Tracks NOT in the library were matched to a DIFFERENT song by the SAME artist
and reported with high confidence instead of as missing — e.g. "Dani
California" -> "Californication" (Red Hot Chili Peppers), "Under The Bridge"
-> "Around the World".

Root cause: _calculate_track_confidence scores 0.5*title + 0.5*artist. A
same-artist comparison always yields artist = 1.0, so the title score is the
only thing that can tell two of an artist's songs apart — but that score is a
SequenceMatcher CHARACTER ratio, which over-credits unrelated titles that
share a long substring ("californi…" = 0.67) or just a stopword ("the" =
0.62). With the flat 0.5 artist term, anything clearing the weak 0.6 char
floor lands at ~0.81-0.83, well over the 0.7 sync threshold. Reproduced on
dev: both reported pairs score 0.81/0.83.

Fix: new core/text/title_match.py:titles_plausibly_same, called in
_calculate_track_confidence right before the floor. It accepts a pair only
when it's near-identical char-wise (>=0.85, so typos / punctuation / casing
like "Beleive"->"Believe", "HUMBLE."->"Humble" still match) OR the titles
share at least one significant (non-stopword) word. Two different songs by the
same artist share no content word, so they're rejected and the real track is
correctly reported missing. ("the" is a stopword — that's what leaked "Under
The Bridge"/"Around the World".)

Scoped deliberately: the word-overlap test fires ONLY when at least one side
has 2+ content words. For single-word titles there is no other word to share,
so it defers to the existing char floor — otherwise legitimate stylized
spellings ("Grey"/"Gray", "Tonite"/"Tonight", "4ever"/"Forever") would become
new false-negatives. Verified those still match. The few single-word variants
that do score low (Ok/Okay, Thru/Through) were already rejected by the
pre-existing length-ratio penalty, not by this gate.

Both reported false positives now score 0.33/0.31 -> missing. Does NOT address
the harder case of two different same-artist songs that DO share a content
word (e.g. "Believe"/"Believer") — pre-existing and unworsened. Any residual
error fails safe: a false-missing is re-downloaded/wishlisted, vs the old
behavior which silently substituted the wrong song.

Tests: tests/test_title_match_guard.py (14) — pure-guard unit tests + a
13-pair battery driving the REAL _calculate_track_confidence (genuine matches
stay >=0.7, same-artist different songs drop below), plus an explicit
no-regression test for stylized single-word spellings. 292 matching/sync tests
pass.
This commit is contained in:
BoulderBadgeDad 2026-06-02 09:14:26 -07:00
parent 3c15041b88
commit 174513d351
3 changed files with 250 additions and 0 deletions

81
core/text/title_match.py Normal file
View file

@ -0,0 +1,81 @@
"""Guard against char-level title false positives in track matching.
Issue #769: playlist sync matched tracks that aren't in the library to a
DIFFERENT song by the SAME artist, with high confidence e.g. "Dani
California" -> "Californication" (Red Hot Chili Peppers), "Under The Bridge"
-> "Around the World". The confidence formula is ``0.5*title + 0.5*artist``,
and a same-artist comparison always yields ``artist = 1.0``, so the title score
is the only thing that can tell two of an artist's songs apart. But the title
score is a ``difflib.SequenceMatcher`` character ratio, which over-credits
unrelated titles that happen to share a long substring ("californi…") or only a
stopword ("the"): 0.67 and 0.62 respectively. With the flat 0.5 artist term
that lands at 0.83 / 0.81 well over the 0.7 sync threshold.
``titles_plausibly_same`` adds a cheap word-level sanity check on top of the
char ratio: accept a pair only when it's near-identical char-wise (so typos and
punctuation/casing variants "Beleive"/"Believe", "HUMBLE."/"Humble" still
match) OR the two titles share at least one significant (non-stopword) token.
Two genuinely different songs by the same artist share no content word, so they
get rejected; the real track is then correctly reported missing.
"""
from __future__ import annotations
import re
# Articles / prepositions / conjunctions only. Deliberately NOT pronouns
# ("you", "me", "i") — those carry meaning in song titles and dropping them
# could strip the only shared word from a real match. "the" MUST stay here:
# without it "Under The Bridge" and "Around the World" would falsely share it.
_TITLE_STOPWORDS = frozenset({
"the", "a", "an", "of", "and", "or", "to", "in", "on",
"for", "with", "at", "by", "from",
})
_TOKEN_RE = re.compile(r"[a-z0-9]+")
# Char ratio at/above which two titles are treated as the same regardless of
# shared words — covers typos, punctuation, casing, accents. Tuned so single-
# word typos ("Beleive"/"Believe" = 0.857) pass while the #769 false positives
# ("Dani California"/"Californication" = 0.667) do not.
_NEAR_IDENTICAL = 0.85
def _content_tokens(text: str) -> set[str]:
return {t for t in _TOKEN_RE.findall((text or "").lower()) if t not in _TITLE_STOPWORDS}
def titles_plausibly_same(
title_a: str,
title_b: str,
char_similarity: float,
*,
near_identical: float = _NEAR_IDENTICAL,
) -> bool:
"""Whether two titles could be the same track, given their char similarity.
``title_a`` / ``title_b`` should already be normalised/cleaned (lowercased,
brackets stripped) the same way the caller computed ``char_similarity``.
Returns ``True`` when the pair is near-identical char-wise OR shares at
least one significant (non-stopword) token. Returns ``False`` for two
titles that are only moderately char-similar and share no content word
i.e. different songs the char ratio over-credited (#769)."""
if char_similarity >= near_identical:
return True
ta = _content_tokens(title_a)
tb = _content_tokens(title_b)
# Word-overlap is only a reliable "different song" signal when at least one
# side has 2+ content words — that's the #769 case where the char ratio
# over-credits a shared substring ("Dani California"/"Californication") or
# a stopword ("Under The Bridge"/"Around the World"). For single-word
# titles there's no other word to share, so applying it would wrongly fail
# legitimate stylized spellings ("Grey"/"Gray", "Tonite"/"Tonight",
# "Thru"/"Through") that the char ratio rightly accepts. In that case defer
# to the caller's existing char-similarity floor instead of force-failing.
if max(len(ta), len(tb)) < 2 or not ta or not tb:
return True
return not ta.isdisjoint(tb)
__all__ = ["titles_plausibly_same"]

View file

@ -7248,6 +7248,22 @@ class MusicDatabase:
# Titles differ in length by more than 30% — penalize heavily # Titles differ in length by more than 30% — penalize heavily
best_title_similarity *= len_ratio best_title_similarity *= len_ratio
# Word-level guard: SequenceMatcher's char ratio over-credits
# different songs that share a long substring or only a stopword
# ("Dani California" vs "Californication" = 0.67; "Under The Bridge"
# vs "Around the World" = 0.62). Since a same-artist comparison
# always scores artist = 1.0, the title is the only discriminator,
# so a bad-but-moderate title score gets carried over the threshold
# (#769). Reject pairs that aren't near-identical AND share no
# significant word — the real track is then reported missing.
from core.text.title_match import titles_plausibly_same
if not titles_plausibly_same(
clean_search_title or search_title_norm,
clean_db_title or db_title_norm,
best_title_similarity,
):
return best_title_similarity * 0.5 # below any threshold
# Require minimum title similarity to prevent a perfect artist match from # Require minimum title similarity to prevent a perfect artist match from
# carrying a bad title match over the threshold (e.g. "Time" vs "Time Flies") # carrying a bad title match over the threshold (e.g. "Time" vs "Time Flies")
if best_title_similarity < 0.6: if best_title_similarity < 0.6:

View file

@ -0,0 +1,153 @@
"""Tests for the title word-overlap guard (#769).
Playlist sync matched tracks NOT in the library to a different song by the
SAME artist with high confidence ("Dani California" -> "Californication";
"Under The Bridge" -> "Around the World"). Root cause: confidence is
0.5*title + 0.5*artist, same-artist always gives artist=1.0, and the title
score is a SequenceMatcher char ratio that over-credits unrelated titles
sharing a substring or a stopword. titles_plausibly_same gates those out.
Two layers tested:
1. titles_plausibly_same in isolation (the pure decision).
2. the real _calculate_track_confidence end-to-end, asserting the two
reported false positives now fall below the 0.7 sync threshold while a
battery of genuine matches stays above it.
"""
from __future__ import annotations
import types
from core.text.title_match import titles_plausibly_same
# ── the pure guard ────────────────────────────────────────────────────────
def test_near_identical_passes_even_without_shared_token():
# Single-word typo: no shared token, but char-identical enough.
assert titles_plausibly_same("beleive", "believe", 0.857) is True
def test_punctuation_casing_variants_pass():
assert titles_plausibly_same("humble", "humble", 0.92) is True
def test_shared_significant_word_passes_below_near_identical():
# Moderate char score but a real shared content word.
assert titles_plausibly_same("hello world", "hello there", 0.6) is True
def test_different_songs_sharing_only_substring_rejected():
# #769: "Dani California" vs "Californication" — share the substring
# "californi" (high char ratio) but no whole word.
assert titles_plausibly_same("dani california", "californication", 0.667) is False
def test_different_songs_sharing_only_stopword_rejected():
# #769: "Under The Bridge" vs "Around the World" — share only "the".
assert titles_plausibly_same("under the bridge", "around the world", 0.625) is False
def test_multiword_stopword_only_overlap_rejected():
# Two 2+-word titles sharing only "the" — the #769 shape.
assert titles_plausibly_same("under the bridge", "around the world", 0.625) is False
def test_single_word_titles_defer_to_char_floor():
# Single content word on each side: no "other word" to share, so the gate
# must NOT force-fail — it defers (returns True) and lets the caller's char
# floor decide. This is what protects stylized spellings like "Grey"/"Gray"
# and "Tonite"/"Tonight" from becoming new false negatives.
assert titles_plausibly_same("grey", "gray", 0.75) is True
assert titles_plausibly_same("tonite", "tonight", 0.77) is True
# ...even when the char score is low — the floor, not the gate, rejects it.
assert titles_plausibly_same("numb", "creep", 0.2) is True
def test_all_stopword_side_defers():
# One side is all stopwords -> no word signal -> defer to char floor.
assert titles_plausibly_same("the the", "around the world", 0.5) is True
# ── end-to-end through the real confidence scorer ──────────────────────────
from database.music_database import MusicDatabase # noqa: E402
_THRESHOLD = 0.7 # services/sync_service.py confidence_threshold
class _FakeTrack:
def __init__(self, title, artist):
self.title = title
self.artist_name = artist
self.track_artist = None
def _scorer():
stub = type("S", (), {})()
for m in (
"_calculate_track_confidence", "_string_similarity",
"_normalize_for_comparison", "_clean_track_title_for_comparison",
):
setattr(stub, m, types.MethodType(getattr(MusicDatabase, m), stub))
return stub
# (source_title, library_title, same_artist, should_match)
_BATTERY = [
# genuine matches — must stay matched
("Mr. Brightside", "Mr Brightside", True),
("HUMBLE.", "Humble", True),
("Beleive", "Believe", True), # typo
("In the End", "In The End", True),
("thank u, next", "Thank U Next", True),
("Old Town Road", "Old Town Road (feat. Billy Ray Cyrus)", True),
("bad guy", "bad guy", True),
# different songs by the SAME artist — must be reported missing
("Dani California", "Californication", False), # the reported case
("Under The Bridge", "Around the World", False), # the reported case
("Otherside", "Californication", False),
("Numb", "In the End", False),
("Yellow", "The Scientist", False),
("Seven Nation Army", "Fell in Love with a Girl", False),
]
def test_confidence_battery_separates_real_from_false_matches():
s = _scorer()
artist = "Red Hot Chili Peppers"
misclassified = []
for src, lib, should_match in _BATTERY:
conf = s._calculate_track_confidence(src, artist, _FakeTrack(lib, artist))
matched = conf >= _THRESHOLD
if matched != should_match:
misclassified.append((src, lib, should_match, round(conf, 3)))
assert not misclassified, f"misclassified: {misclassified}"
def test_reported_false_positives_now_below_threshold():
s = _scorer()
a = "Red Hot Chili Peppers"
assert s._calculate_track_confidence("Dani California", a, _FakeTrack("Californication", a)) < _THRESHOLD
assert s._calculate_track_confidence("Under The Bridge", a, _FakeTrack("Around the World", a)) < _THRESHOLD
def test_exact_title_same_artist_still_perfect():
s = _scorer()
a = "Garbage"
conf = s._calculate_track_confidence("Only Happy When It Rains", a,
_FakeTrack("Only Happy When It Rains", a))
assert conf >= 0.99
def test_single_word_spelling_variants_not_regressed():
# The gate must not turn legitimate stylized single-word spellings into
# new "missing" reports (the regression the first cut of this fix had).
# These all matched before #769's gate and must still match.
s = _scorer()
a = "Some Artist"
for src, lib in [("Grey", "Gray"), ("Tonite", "Tonight"),
("4ever", "Forever"), ("Lovin'", "Loving"), ("Colour", "Color")]:
conf = s._calculate_track_confidence(src, a, _FakeTrack(lib, a))
assert conf >= _THRESHOLD, f"{src!r}->{lib!r} regressed to {conf:.3f}"