Files
yellowjacket/backend/autotag/distance.go
T
2026-05-01 11:52:50 -04:00

156 lines
3.5 KiB
Go

package autotag
// levenshtein returns the Levenshtein edit distance between a and
// b, operating on runes so multi-byte characters count as one edit.
// Allocates a single O(min(len)) scratch slice.
func levenshtein(a, b string) int {
ra := []rune(a)
rb := []rune(b)
if len(ra) == 0 {
return len(rb)
}
if len(rb) == 0 {
return len(ra)
}
if len(ra) > len(rb) {
ra, rb = rb, ra
}
prev := make([]int, len(ra)+1)
for i := range prev {
prev[i] = i
}
for j := 1; j <= len(rb); j++ {
curr0 := prev[0]
prev[0] = j
for i := 1; i <= len(ra); i++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
newVal := min3(
prev[i]+1, // deletion
prev[i-1]+1, // insertion
curr0+cost, // substitution
)
curr0 = prev[i]
prev[i] = newVal
}
}
return prev[len(ra)]
}
func min3(a, b, c int) int {
if a < b {
if a < c {
return a
}
return c
}
if b < c {
return b
}
return c
}
// titleSimilarity returns a score in [0, 1] from normalized edit
// distance. 1.0 means identical after normalization, 0.0 means
// fully dissimilar. Both sides are normalized inside.
func titleSimilarity(a, b string) float64 {
na := Normalize(a)
nb := Normalize(b)
if na == "" && nb == "" {
return 1.0
}
longest := len(na)
if len(nb) > longest {
longest = len(nb)
}
if longest == 0 {
return 0.0
}
dist := levenshtein(na, nb)
return 1.0 - float64(dist)/float64(longest)
}
// Scoring weights for the per-track distance function. Local reads
// only — never written at runtime, so no mutex. Values stay small
// so a future tuning pass can nudge them without rescaling.
const (
weightTitle = 0.60
weightLength = 0.30
weightTrackNumber = 0.10
// Length deltas at or below lengthExactMs score 1.0 — matches
// the frontend's "subtle drift" threshold so anything the UI
// hides also doesn't count against the score. Past the
// threshold the penalty scales with delta / candidateMs (i.e.
// percentage of candidate-track length): a 5 s delta on a
// 4 min track is small, the same delta on a 30 s interlude is
// huge. At lengthFullyWrongPct of candidate length the score
// hits zero; beyond that it stays clamped to zero.
lengthExactMs int64 = 2000
lengthFullyWrongPct float64 = 0.20
// A title below titleReject has too little signal for this
// alignment to count as matched.
titleReject = 0.60
)
// lengthScore returns 1.0 for deltas <= lengthExactMs, 0.0 for
// deltas >= lengthFullyWrongPct of the candidate length, linear
// in delta-as-percentage-of-candidate-length between. When
// either side is zero (unknown), returns 0.5 so length is treated
// as neutral.
func lengthScore(localMs, candidateMs int64) float64 {
if localMs <= 0 || candidateMs <= 0 {
return 0.5
}
delta := localMs - candidateMs
if delta < 0 {
delta = -delta
}
if delta <= lengthExactMs {
return 1.0
}
pct := float64(delta) / float64(candidateMs)
if pct >= lengthFullyWrongPct {
return 0.0
}
return 1.0 - pct/lengthFullyWrongPct
}
// trackDistance scores how well one local track aligns with one
// candidate track. Higher is better. Caller decides what to do
// with the result — this function has no threshold.
func trackDistance(local LocalTrack, cand CandidateTrack) float64 {
title := titleSimilarity(local.Title, cand.Title)
length := lengthScore(local.LengthMillis, cand.LengthMillis)
var trackOK float64
if local.TrackNumber > 0 && local.TrackNumber == cand.Position {
trackOK = 1.0
}
return title*weightTitle + length*weightLength + trackOK*weightTrackNumber
}