`ConfidentTier` and `Confident()` are a name for what was about to be written as `== RecommendationStrong` at two call sites: the album page telling the user unprompted that there is a match for what they are looking at (#28), and strict auto-accept rewriting files without asking (#90). A page that claims confidence the auto-accept pass would decline is the app contradicting itself, and #90 asks for exactly this — that the two agree on what "high confidence" means rather than computing it twice. What they do not share is written down beside it. Surfacing a match is a suggestion with a confirm dialog behind it; auto-accept is an irreversible on-disk rewrite gated on further conditions the tier cannot express — exact track count, every title matching, lengths within a couple of seconds, no cover replacement, no MBID conflict. So this is the floor both stand on, not the whole of either test. `Confident` is a rank comparison rather than an equality, so a tier added above "strong" later does not silently stop qualifying.
169 lines
5.5 KiB
Go
169 lines
5.5 KiB
Go
package autotag
|
|
|
|
// Recommendation is a qualitative confidence tier for a group's
|
|
// ranked candidates — the piece a raw score can't express on its
|
|
// own. Modeled on beets' Recommendation enum: the tier starts from
|
|
// the top candidate's absolute score and is then CAPPED by defects
|
|
// (ambiguity with a different release group, missing/unmatched
|
|
// tracks, thin evidence). Auto-accept (plan 011) should require
|
|
// RecommendationStrong; the review UI can badge the rest.
|
|
type Recommendation string
|
|
|
|
// Recommendation tiers, weakest to strongest.
|
|
const (
|
|
RecommendationNone Recommendation = "none"
|
|
RecommendationLow Recommendation = "low"
|
|
RecommendationMedium Recommendation = "medium"
|
|
RecommendationStrong Recommendation = "strong"
|
|
)
|
|
|
|
// ConfidentTier is the tier at which this package considers a match
|
|
// good enough to act on without being asked to look.
|
|
//
|
|
// It exists as a name rather than as `== RecommendationStrong` at
|
|
// each call site because two features read it and they must not
|
|
// disagree about what "high confidence" means: the album page tells
|
|
// the user unprompted that the autotagger has a match (#28), and
|
|
// strict auto-accept will rewrite the files without asking (#90).
|
|
// A page that says "we are sure" about something the auto-accept
|
|
// pass would decline is the app contradicting itself.
|
|
//
|
|
// What the two do *not* share is everything else. Surfacing a match
|
|
// is a suggestion with a confirm dialog behind it; auto-accept is an
|
|
// irreversible on-disk rewrite, and #90 gates it on further
|
|
// conditions this tier cannot express — exact track count, every
|
|
// title matching, lengths within a couple of seconds, no cover
|
|
// replacement, no MBID conflict. So this is the floor both stand on,
|
|
// not the whole of either test.
|
|
const ConfidentTier = RecommendationStrong
|
|
|
|
// Confident reports whether a tier clears ConfidentTier.
|
|
//
|
|
// A comparison rather than an equality, so adding a tier above
|
|
// "strong" later does not silently stop qualifying.
|
|
func Confident(r Recommendation) bool {
|
|
return recommendationRank(r) >= recommendationRank(ConfidentTier)
|
|
}
|
|
|
|
const (
|
|
// Absolute score tiers.
|
|
strongScoreThresh = 0.90
|
|
mediumScoreThresh = 0.75
|
|
|
|
// A runner-up from a DIFFERENT release group within this margin
|
|
// of the top score makes the match ambiguous — two genuinely
|
|
// different albums both fit, so a human should look. Editions
|
|
// of the same release group are expected to score nearly
|
|
// identically and never count as ambiguity.
|
|
ambiguityMargin = 0.05
|
|
)
|
|
|
|
// Recommend derives the confidence tier for a ranked candidate
|
|
// list. candidates must already be sorted best-first (the shape
|
|
// RankCandidates returns).
|
|
func Recommend(g Group, candidates []Candidate) Recommendation {
|
|
if len(candidates) == 0 {
|
|
return RecommendationNone
|
|
}
|
|
|
|
top := candidates[0]
|
|
|
|
var rec Recommendation
|
|
|
|
switch {
|
|
case top.Score >= strongScoreThresh:
|
|
rec = RecommendationStrong
|
|
case top.Score >= mediumScoreThresh:
|
|
rec = RecommendationMedium
|
|
default:
|
|
return RecommendationLow
|
|
}
|
|
|
|
// Cap: a different release group scoring within the ambiguity
|
|
// margin means the score alone can't pick between two albums.
|
|
if rivalWithinMargin(top, candidates[1:]) {
|
|
rec = minRecommendation(rec, RecommendationMedium)
|
|
}
|
|
|
|
// Cap: missing or unmatched tracks mean the alignment itself is
|
|
// incomplete, however good the matched tracks look (beets caps
|
|
// these penalties at "medium" the same way). A synthetic
|
|
// (tag-clustered) group is, by construction, a subset of a
|
|
// bigger folder, so AlignmentMissing (the candidate has tracks
|
|
// the group doesn't) is the expected shape rather than a defect
|
|
// and doesn't cap the recommendation. AlignmentUnmatched (the
|
|
// group has a track the candidate doesn't) is still a real
|
|
// discrepancy regardless of source.
|
|
for _, a := range top.Alignments {
|
|
if a.Status == AlignmentUnmatched ||
|
|
(a.Status == AlignmentMissing && !g.Synthetic) {
|
|
rec = minRecommendation(rec, RecommendationMedium)
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
// Cap: tiny folders can't corroborate a match strongly enough
|
|
// to act on without review, whatever the arithmetic says.
|
|
if len(g.Tracks) < evidenceFullTracks {
|
|
rec = minRecommendation(rec, RecommendationMedium)
|
|
}
|
|
|
|
return rec
|
|
}
|
|
|
|
// rivalWithinMargin reports whether any candidate from a different
|
|
// release group scores within ambiguityMargin of the top candidate.
|
|
func rivalWithinMargin(top Candidate, rest []Candidate) bool {
|
|
for _, c := range rest {
|
|
if top.Score-c.Score > ambiguityMargin {
|
|
// Sorted descending: everything further is farther away.
|
|
return false
|
|
}
|
|
|
|
if !sameReleaseGroup(top, c) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// sameReleaseGroup reports whether two candidates belong to the
|
|
// same release group — by MBID when both carry one, by normalized
|
|
// title + artist-credit otherwise (local candidates may lack RG
|
|
// MBIDs).
|
|
func sameReleaseGroup(a, b Candidate) bool {
|
|
if a.ReleaseGroupMBID != "" && b.ReleaseGroupMBID != "" {
|
|
return a.ReleaseGroupMBID == b.ReleaseGroupMBID
|
|
}
|
|
|
|
return Normalize(a.Title) == Normalize(b.Title) &&
|
|
Normalize(a.ArtistCredit) == Normalize(b.ArtistCredit)
|
|
}
|
|
|
|
// recommendationRank orders tiers for min-comparison.
|
|
func recommendationRank(r Recommendation) int {
|
|
switch r {
|
|
case RecommendationNone:
|
|
return 0
|
|
case RecommendationLow:
|
|
return 1
|
|
case RecommendationMedium:
|
|
return 2
|
|
case RecommendationStrong:
|
|
return 3
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// minRecommendation returns the weaker of two tiers.
|
|
func minRecommendation(a, b Recommendation) Recommendation {
|
|
if recommendationRank(a) <= recommendationRank(b) {
|
|
return a
|
|
}
|
|
|
|
return b
|
|
}
|