diff --git a/ROADMAP.md b/ROADMAP.md index d931f38..f386d71 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,6 +13,7 @@ - [ ] Database V2 read-only parser - [x] Configuration - [x] Logging +- [x] Matching engine ## v0.2 — Diagnostics diff --git a/docs/design/matching-engine.md b/docs/design/matching-engine.md new file mode 100644 index 0000000..8a72e32 --- /dev/null +++ b/docs/design/matching-engine.md @@ -0,0 +1,29 @@ +# Explainable Matching Engine + +## Problem + +Missing references need ranked candidate files, but a filename-only yes/no check +cannot explain ambiguity or cloud-provider conflict names. + +## Architecture + +The read-only matching engine indexes normalized filenames and scores only related +candidates. Every score contains evidence for filename, extension, and parent +folder. Exact filenames earn 60 points, normalized names 55, numeric conflict-name +matches 50, extensions 10, and parent folders 20. + +The displayed percentage is an evidence score, not a statistical probability. +Metadata, duration, hashes, and fingerprints can add stronger evidence later. + +## Edge Cases + +- Unicode and case differences. +- OneDrive-style names such as `Track 2.mp3`. +- Duplicate candidates in different folders. +- Legitimate numbered song titles, which remain candidates but are never repaired. +- Unrelated names, which are not emitted as candidates. + +## Verification + +Tests cover exact, normalized, conflict-suffix, ambiguous, and unrelated filenames. +Candidate ordering is deterministic. The engine never changes a track or reference. diff --git a/serato_doctor/matching.py b/serato_doctor/matching.py new file mode 100644 index 0000000..61686c6 --- /dev/null +++ b/serato_doctor/matching.py @@ -0,0 +1,86 @@ +import re +import unicodedata +from collections import defaultdict +from pathlib import Path +from typing import DefaultDict, Iterable, List, Tuple + +from serato_doctor.models.match import MatchEvidence, TrackMatch +from serato_doctor.models.reference import TrackReference +from serato_doctor.models.track import DiskTrack + + +def normalize(value: str) -> str: + return unicodedata.normalize("NFKC", value).casefold() + + +def cloud_conflict_name(filename: str) -> str: + """Remove a trailing numeric cloud-conflict suffix from a filename stem.""" + + path = Path(filename) + stem = re.sub(r" \d+$", "", path.stem) + return normalize(stem + path.suffix) + + +def score_candidate(reference: TrackReference, track: DiskTrack) -> TrackMatch: + reference_name = reference.filename + track_name = track.filename + + if reference_name == track_name: + filename_points = 60 + filename_reason = "Filename is identical" + elif normalize(reference_name) == normalize(track_name): + filename_points = 55 + filename_reason = "Filename matches after case and Unicode normalization" + elif cloud_conflict_name(reference_name) == cloud_conflict_name(track_name): + filename_points = 50 + filename_reason = "Filename matches after removing a numeric conflict suffix" + else: + filename_points = 0 + filename_reason = "Filename does not match" + + same_extension = normalize(reference.path.suffix) == normalize(track.suffix) + same_parent = normalize(reference.path.parent.name) == normalize( + track.path.parent.name + ) + evidence = ( + MatchEvidence( + "filename", + filename_points > 0, + filename_points, + 60, + filename_reason, + ), + MatchEvidence( + "extension", + same_extension, + 10 if same_extension else 0, + 10, + "File extension matches" if same_extension else "File extension differs", + ), + MatchEvidence( + "parent_folder", + same_parent, + 20 if same_parent else 0, + 20, + "Parent folder matches" if same_parent else "Parent folder differs", + ), + ) + return TrackMatch(reference, track, evidence) + + +class MatchingEngine: + """Find and rank filename-related disk candidates without modifying files.""" + + def __init__(self, tracks: Iterable[DiskTrack]): + self._by_conflict_name: DefaultDict[str, List[DiskTrack]] = defaultdict(list) + for track in tracks: + self._by_conflict_name[cloud_conflict_name(track.filename)].append(track) + + def candidates_for(self, reference: TrackReference) -> Tuple[TrackMatch, ...]: + candidates = self._by_conflict_name.get( + cloud_conflict_name(reference.filename), [] + ) + matches = [score_candidate(reference, track) for track in candidates] + return tuple( + sorted(matches, key=lambda match: (-match.score, str(match.track.path))) + ) diff --git a/serato_doctor/models/__init__.py b/serato_doctor/models/__init__.py index 54dc054..217820c 100644 --- a/serato_doctor/models/__init__.py +++ b/serato_doctor/models/__init__.py @@ -1,6 +1,15 @@ from serato_doctor.models.crate import Crate from serato_doctor.models.library import Library +from serato_doctor.models.match import MatchEvidence, TrackMatch from serato_doctor.models.reference import ReferenceResult, TrackReference from serato_doctor.models.track import DiskTrack -__all__ = ["Crate", "DiskTrack", "Library", "ReferenceResult", "TrackReference"] +__all__ = [ + "Crate", + "DiskTrack", + "Library", + "MatchEvidence", + "ReferenceResult", + "TrackMatch", + "TrackReference", +] diff --git a/serato_doctor/models/match.py b/serato_doctor/models/match.py new file mode 100644 index 0000000..72279a3 --- /dev/null +++ b/serato_doctor/models/match.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass +from typing import Tuple + +from serato_doctor.models.reference import TrackReference +from serato_doctor.models.track import DiskTrack + + +@dataclass(frozen=True) +class MatchEvidence: + """One explainable scoring decision for a candidate track.""" + + field: str + matched: bool + points: int + max_points: int + explanation: str + + +@dataclass(frozen=True) +class TrackMatch: + """A ranked candidate backed by explicit, inspectable evidence.""" + + reference: TrackReference + track: DiskTrack + evidence: Tuple[MatchEvidence, ...] + + @property + def score(self) -> int: + return sum(item.points for item in self.evidence) + + @property + def max_score(self) -> int: + return sum(item.max_points for item in self.evidence) + + @property + def score_percent(self) -> float: + if not self.max_score: + return 0.0 + return round(self.score / self.max_score * 100, 1) diff --git a/tests/test_matcher.py b/tests/test_matcher.py new file mode 100644 index 0000000..2f06919 --- /dev/null +++ b/tests/test_matcher.py @@ -0,0 +1,62 @@ +from pathlib import Path + +from serato_doctor.matching import MatchingEngine, score_candidate +from serato_doctor.models.reference import TrackReference +from serato_doctor.models.track import DiskTrack + + +def reference(filename, folder="House"): + return TrackReference( + Path("Test.crate"), Path("/old") / folder / filename, filename + ) + + +def track(filename, folder="House"): + path = Path("/new") / folder / filename + return DiskTrack(path, filename, 100, path.suffix.lower()) + + +def test_exact_candidate_has_full_evidence_score(): + match = score_candidate(reference("Track.mp3"), track("Track.mp3")) + + assert match.score == 90 + assert match.max_score == 90 + assert match.score_percent == 100.0 + assert all(item.matched for item in match.evidence) + + +def test_cloud_conflict_suffix_is_explained(): + match = score_candidate(reference("Track.mp3"), track("Track 2.mp3")) + + assert match.score == 80 + assert match.score_percent == 88.9 + assert match.evidence[0].explanation == ( + "Filename matches after removing a numeric conflict suffix" + ) + + +def test_case_normalized_match_scores_below_exact(): + match = score_candidate(reference("TRACK.MP3"), track("track.mp3")) + + assert match.score == 85 + assert match.evidence[0].points == 55 + + +def test_engine_omits_unrelated_filenames(): + engine = MatchingEngine([track("Different.mp3")]) + + assert engine.candidates_for(reference("Missing.mp3")) == () + + +def test_ambiguous_candidates_are_ranked_deterministically(): + engine = MatchingEngine( + [track("Track 2.mp3", "Other"), track("Track 3.mp3", "House")] + ) + + matches = engine.candidates_for(reference("Track.mp3")) + + assert [match.track.filename for match in matches] == [ + "Track 3.mp3", + "Track 2.mp3", + ] + assert [match.score for match in matches] == [80, 60]