feat(verification): shared evaluate() PASS/SKIP/FAIL decision core

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dev 2026-06-10 14:46:32 +02:00
parent cf0a17c14a
commit d989f25220
2 changed files with 242 additions and 1 deletions

View file

@ -11,12 +11,32 @@ gate and duration guard are defined exactly once.
""" """
import re import re
from dataclasses import dataclass
from difflib import SequenceMatcher from difflib import SequenceMatcher
from enum import Enum
from typing import Any, List, Optional
# Thresholds — the single definition both paths share. # Thresholds — the single definition both paths share.
MIN_ACOUSTID_SCORE = 0.80 # Minimum fingerprint score to trust a match. MIN_ACOUSTID_SCORE = 0.80 # Minimum fingerprint score to trust a match.
TITLE_MATCH_THRESHOLD = 0.70 # Title similarity to consider a match. TITLE_MATCH_THRESHOLD = 0.70 # Title similarity to consider a match.
ARTIST_MATCH_THRESHOLD = 0.60 # Artist similarity to consider a match. ARTIST_MATCH_THRESHOLD = 0.60 # Artist similarity to consider a match.
CLEAR_MISMATCH_THRESHOLD = 0.30 # Below this artist sim = clear wrong song.
class Decision(Enum):
PASS = "pass"
SKIP = "skip"
FAIL = "fail"
@dataclass
class Outcome:
decision: Decision
title_sim: float = 0.0
artist_sim: float = 0.0
matched_title: str = ""
matched_artist: str = ""
reason: str = ""
def normalize(text: str) -> str: def normalize(text: str) -> str:
@ -57,3 +77,172 @@ def similarity(a: str, b: str) -> float:
if na == nb: if na == nb:
return 1.0 return 1.0
return SequenceMatcher(None, na, nb).ratio() return SequenceMatcher(None, na, nb).ratio()
_match_engine = None
def _detect_title_version(title: str) -> str:
"""Version label ('original'/'instrumental'/'live'/'remix'/...) for a title."""
global _match_engine
if not title:
return 'original'
if _match_engine is None:
from core.matching_engine import MusicMatchingEngine
_match_engine = MusicMatchingEngine()
version_type, _ = _match_engine.detect_version_type(title)
return version_type
def _alias_aware_artist_sim(expected_artist: str, actual_artist: str,
aliases: Optional[Any] = None) -> float:
"""Best artist similarity across (expected, *aliases) vs actual.
Bridges cross-script artist comparisons (kanjiromaji etc) when MusicBrainz
aliases are available. ``aliases`` is an iterable of alias strings, or a
callable resolving them lazily (only invoked when direct similarity falls
below threshold keeps the happy path lookup-free).
"""
from core.matching.artist_aliases import artist_names_match
direct = similarity(expected_artist, actual_artist)
if aliases is None:
return direct
if direct >= ARTIST_MATCH_THRESHOLD:
return direct
resolved = aliases() if callable(aliases) else aliases
if not resolved:
return direct
_matched, score = artist_names_match(
expected_artist, actual_artist, aliases=resolved,
threshold=ARTIST_MATCH_THRESHOLD, similarity=similarity,
)
return score
def _find_best_title_artist_match(recordings, expected_title, expected_artist,
aliases=None):
"""Return (best_recording, title_sim, artist_sim) — title weighted higher."""
best_rec = None
best_title_sim = 0.0
best_artist_sim = 0.0
best_combined = 0.0
for rec in recordings:
title = rec.get('title') or ''
artist = rec.get('artist') or ''
title_sim = similarity(expected_title, title)
artist_sim = _alias_aware_artist_sim(expected_artist, artist, aliases)
combined = (title_sim * 0.6) + (artist_sim * 0.4)
if combined > best_combined:
best_combined = combined
best_rec = rec
best_title_sim = title_sim
best_artist_sim = artist_sim
return best_rec, best_title_sim, best_artist_sim
def evaluate(expected_title: str, expected_artist: str,
recordings: List[dict], *, fingerprint_score: float,
file_duration_s: Optional[float] = None,
aliases_provider: Optional[Any] = None) -> Outcome:
"""Decide PASS / SKIP / FAIL for a fingerprinted file against expected
title/artist. Pure: no I/O. Shared by import verification and library scan.
``aliases_provider``: iterable or callable of expected-artist aliases
(kanji/cyrillic/etc) used to bridge cross-script comparisons.
``file_duration_s``: when provided, a strong duration mismatch downgrades a
would-be FAIL to SKIP (fingerprint hash collision guard, used by the scan).
"""
from core.matching.script_compat import is_cross_script_mismatch
from core.matching.acoustid_candidates import (
duration_mismatches_strongly, find_matching_recording,
)
from core.matching.version_mismatch import is_acceptable_version_mismatch
best_rec, title_sim, artist_sim = _find_best_title_artist_match(
recordings, expected_title, expected_artist, aliases_provider,
)
if not best_rec:
return Outcome(Decision.SKIP, reason="No recordings with title/artist info")
matched_title = best_rec.get('title', '?') or '?'
matched_artist = best_rec.get('artist', '?') or '?'
def out(dec, reason):
return Outcome(dec, title_sim, artist_sim, matched_title, matched_artist, reason)
# Version gate: original vs instrumental/live/remix is a real difference.
expected_version = _detect_title_version(expected_title)
matched_version = _detect_title_version(matched_title)
if expected_version != matched_version:
if not is_acceptable_version_mismatch(
expected_version, matched_version,
fingerprint_score=fingerprint_score,
title_similarity=title_sim, artist_similarity=artist_sim,
):
return out(Decision.FAIL,
f"Version mismatch: expected ({expected_version}) "
f"but file is ({matched_version})")
# Clean match.
if title_sim >= TITLE_MATCH_THRESHOLD and artist_sim >= ARTIST_MATCH_THRESHOLD:
return out(Decision.PASS, "Audio verified")
# Title matches, artist doesn't — cover/collab vs genuinely wrong.
if title_sim >= TITLE_MATCH_THRESHOLD and artist_sim < ARTIST_MATCH_THRESHOLD:
for rec in recordings:
if _alias_aware_artist_sim(
expected_artist, rec.get('artist', ''), aliases_provider,
) >= ARTIST_MATCH_THRESHOLD:
return out(Decision.PASS, "Expected artist found in AcoustID results")
if artist_sim < CLEAR_MISMATCH_THRESHOLD:
if file_duration_s and duration_mismatches_strongly(
file_duration_s, best_rec.get('duration') or best_rec.get('length')):
return out(Decision.SKIP, "Duration mismatch (fingerprint collision)")
return out(Decision.FAIL,
f"Audio mismatch: '{matched_title}' by '{matched_artist}' "
f"— expected artist not found")
return out(Decision.SKIP, "Title matches but artist ambiguous (cover/collab?)")
# Title doesn't match — scan all recordings for a version-matched hit.
def _title_sim(a, b):
return similarity(a, b)
def _artist_sim(ea, aa):
return _alias_aware_artist_sim(ea, aa, aliases_provider)
candidate = None
for rec in recordings:
if _detect_title_version(rec.get('title') or '') != expected_version:
continue
if (similarity(expected_title, rec.get('title') or '') >= TITLE_MATCH_THRESHOLD
and _alias_aware_artist_sim(
expected_artist, rec.get('artist', ''), aliases_provider,
) >= ARTIST_MATCH_THRESHOLD):
candidate = rec
break
if candidate is not None:
return out(Decision.PASS, "Scan match found in AcoustID results")
# High-confidence / cross-script skips (don't quarantine a correct file).
has_non_ascii = (any(ord(c) > 127 for c in (expected_title or ''))
or any(ord(c) > 127 for c in matched_title))
language_script_skip = (fingerprint_score >= 0.95 and has_non_ascii
and artist_sim >= ARTIST_MATCH_THRESHOLD)
high_confidence_strong_match_skip = (fingerprint_score >= 0.95
and title_sim >= 0.80
and artist_sim >= ARTIST_MATCH_THRESHOLD)
cross_script_artist_skip = (fingerprint_score >= MIN_ACOUSTID_SCORE
and artist_sim >= ARTIST_MATCH_THRESHOLD
and is_cross_script_mismatch(expected_artist, matched_artist))
if (language_script_skip or high_confidence_strong_match_skip
or cross_script_artist_skip):
return out(Decision.SKIP, "Likely same song in different language/script")
if file_duration_s and duration_mismatches_strongly(
file_duration_s, best_rec.get('duration') or best_rec.get('length')):
return out(Decision.SKIP, "Duration mismatch (fingerprint collision)")
return out(Decision.FAIL,
f"Audio mismatch: file identified as '{matched_title}' by "
f"'{matched_artist}', expected '{expected_title}' by '{expected_artist}'")

View file

@ -4,7 +4,59 @@ One place for normalization + the PASS/SKIP/FAIL decision used by BOTH import-ti
verification and the library AcoustID scan, so the two paths can't drift apart. verification and the library AcoustID scan, so the two paths can't drift apart.
""" """
from core.matching.audio_verification import normalize from core.matching.audio_verification import normalize, evaluate, Decision
def _rec(title, artist, duration=None):
return {"title": title, "artist": artist, "duration": duration}
def test_cross_script_vocal_credit_clean_match_passes():
# Sawano / 澤野弘之 <Vocal: ...> — <> stripped, alias bridges the artist,
# title matches too -> clean PASS (must never FAIL/quarantine).
out = evaluate(
"Call Your Name", "Sawano Hiroyuki",
[_rec("call your name", "澤野弘之 <Vocal: mpi & CASG>")],
fingerprint_score=0.95,
aliases_provider=lambda: ["澤野弘之"],
)
assert out.decision == Decision.PASS
def test_cross_script_ipa_title_skips_not_fails():
# AcoustID returns an IPA-transcribed title that can't string-match, but the
# artist bridges cross-script -> SKIP (import anyway), never FAIL.
out = evaluate(
"Attack on Titan", "Sawano Hiroyuki",
[_rec("ətˈæk 0N tάɪtn", "澤野弘之")],
fingerprint_score=0.95,
aliases_provider=lambda: ["澤野弘之"],
)
assert out.decision == Decision.SKIP
def test_clean_cross_script_match_passes():
out = evaluate(
"Xl-Tt", "Sawano Hiroyuki",
[_rec("xl-tt", "澤野弘之")],
fingerprint_score=0.95,
aliases_provider=lambda: ["澤野弘之"],
)
assert out.decision == Decision.PASS
def test_genuine_wrong_song_fails():
out = evaluate(
"Yellow", "Coldplay",
[_rec("Rich Interlude", "Kendrick Lamar")],
fingerprint_score=0.85,
)
assert out.decision == Decision.FAIL
def test_no_recordings_skips():
out = evaluate("Whatever", "Someone", [], fingerprint_score=0.9)
assert out.decision == Decision.SKIP
def test_normalize_strips_paren_bracket_angle_and_keeps_cjk(): def test_normalize_strips_paren_bracket_angle_and_keeps_cjk():