From aff528358ed8ae870871e3300239b3de7742ebf1 Mon Sep 17 00:00:00 2001 From: Silo Server Developer Date: Wed, 27 May 2026 20:10:55 +0200 Subject: [PATCH] test(matcher): exercise the single-distinct-show guard properly + conflicting-ID case; doc notes Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/metadata/match_candidates.go | 5 +++ .../metadata/match_candidates_select_test.go | 39 ++++++++++++++----- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/internal/metadata/match_candidates.go b/internal/metadata/match_candidates.go index e13306c9..10650de8 100644 --- a/internal/metadata/match_candidates.go +++ b/internal/metadata/match_candidates.go @@ -349,6 +349,8 @@ type scoredMatchCandidate struct { // a TMDB row that weren't merged because each carries only its own provider's // ID). Candidates that share a canonical provider key but carry different values // are considered distinct shows and cause the function to return false. +// Candidates with Year == 0 are treated as year-mismatched (a provider that +// omitted the year yields false here) — conservative by design. func candidatesAreSingleDistinctShow(best MatchCandidate, scored []scoredMatchCandidate) bool { // Year==0 means the provider didn't supply a release year, so we cannot // claim the candidates refer to the *same* show via year-equality. Without @@ -410,6 +412,9 @@ func selectInitialMatchCandidate(hints *MatchHints, candidates []MatchCandidate) // year is high-confidence even when the fuzzy title score sits in the 55-69 // band (short/numeric/alternate titles). Accept the top-ranked candidate // without lowering the score thresholds. + // Residual risk: two different shows with an identical title+year and no + // provider IDs would both pass; accepted as low-risk given the title+year+type + // corroboration. if hints.Year != 0 && best.candidate.Year == hints.Year && candidateTypeMatchesHint(hints.Type, best.candidate.ContentType) && candidatesAreSingleDistinctShow(best.candidate, scoredCandidates) { diff --git a/internal/metadata/match_candidates_select_test.go b/internal/metadata/match_candidates_select_test.go index df7685e8..ed2af379 100644 --- a/internal/metadata/match_candidates_select_test.go +++ b/internal/metadata/match_candidates_select_test.go @@ -38,20 +38,39 @@ func TestSelectInitialMatchCandidate_LoneResultYearMismatchStillRejected(t *test } func TestSelectInitialMatchCandidate_TwoDifferentShowsUnchanged(t *testing.T) { - // Two DIFFERENT shows (different titles, similar scores => gap=0 => tie-break path). - // candidatesAreSingleDistinctShow returns false (titles differ), so the new rule - // must NOT fire; falls through to the existing tie-break which returns nil because - // DetailScore is 0. Guards against over-accepting distinct results. + // Two genuinely different shows that BOTH score >=55 (so the new rule IS evaluated, + // not short-circuited by the <55 floor): candidatesAreSingleDistinctShow must return + // false (titles differ), so the new rule does NOT fire and behavior falls through to + // the existing tie-break (which returns nil here because DetailScore is 0). Guards + // against over-accepting distinct results. // - // "The Show Special" and "The Show Extra" both score 38 against hint "The Show" / - // 2010 (0-similarity title match, 1 source, 1 provider ID), gap = 0 < 15 => - // tie-break; inferTitleSimilarity between the two candidates is 0 => not same show. - hints := &MatchHints{Title: "The Show", Year: 2010, Type: "movie"} + // Each candidate shares 7 tokens with the hint plus one distinct trailing word, so + // each is coherent with the hint (Jaccard 7/8 = 0.875 >= 0.85 => sim 0.8 => +28) and + // scores 28 + 20(year) + 24(2 sources) + 5 + 1(1 id) = 78 (>=55, reaches the guard). + // The two candidates differ from each other (Jaccard 7/9 = 0.78 < 0.85 => sim 0), so + // candidatesAreSingleDistinctShow returns false. Equal scores => gap 0 < 15 => tie-break. + hints := &MatchHints{Title: "The Real History of the World War", Year: 2010, Type: "series"} cands := []MatchCandidate{ - {Title: "The Show Special", Year: 2010, ContentType: "movie", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "1"}}, - {Title: "The Show Extra", Year: 2010, ContentType: "movie", Sources: []string{"tmdb"}, ProviderIDs: map[string]string{"tmdb": "2"}}, + {Title: "The Real History of the World War Europe", Year: 2010, ContentType: "series", Sources: []string{"tvdb", "tmdb"}, ProviderIDs: map[string]string{"tvdb": "1"}}, + {Title: "The Real History of the World War Pacific", Year: 2010, ContentType: "series", Sources: []string{"tvdb", "tmdb"}, ProviderIDs: map[string]string{"tvdb": "2"}}, } if _, ok := selectInitialMatchCandidate(hints, cands); ok { t.Fatalf("two distinct shows must not be auto-accepted by the lone-result rule") } } + +func TestSelectInitialMatchCandidate_ConflictingProviderIDsNotAccepted(t *testing.T) { + // Same title+year but different tmdb IDs => two distinct shows; must NOT auto-accept. + // Each scores 65 (45 exact title + 20 year) + 5 + 1(richness) = ... actually + // 45+20+5+1 = 71 (no sources, one provider ID), well above the 55 floor, so the new + // branch is reached. candidatesAreSingleDistinctShow must return false because the two + // candidates carry the same canonical provider key (tmdb) with conflicting values. + hints := &MatchHints{Title: "Alpha", Year: 2022, Type: "movie"} + cands := []MatchCandidate{ + {Title: "Alpha", Year: 2022, ContentType: "movie", ProviderIDs: map[string]string{"tmdb": "111"}}, + {Title: "Alpha", Year: 2022, ContentType: "movie", ProviderIDs: map[string]string{"tmdb": "222"}}, + } + if _, ok := selectInitialMatchCandidate(hints, cands); ok { + t.Fatal("conflicting tmdb IDs must not be auto-accepted") + } +}