package match import ( "strings" "unicode/utf8" ) // levenshtein returns the edit distance between two strings, measured in // runes. It uses the rolling-row formulation: O(min(n,m)) memory. // // The resolver uses it only to tolerate typos in the *street name*; house // numbers are always compared exactly, because a wrong house is worse than a // missing one. func levenshtein(a, b string) int { ra, rb := []rune(a), []rune(b) if len(ra) == 0 { return len(rb) } if len(rb) == 0 { return len(ra) } prev := make([]int, len(rb)+1) curr := make([]int, len(rb)+1) for j := range prev { prev[j] = j } for i := 1; i <= len(ra); i++ { curr[0] = i for j := 1; j <= len(rb); j++ { cost := 1 if ra[i-1] == rb[j-1] { cost = 0 } curr[j] = min3(prev[j]+1, curr[j-1]+1, prev[j-1]+cost) } prev, curr = curr, prev } return prev[len(rb)] } // ratio returns a 0..1 similarity where 1 means identical. It is // 1 - distance/max(len), which is stable and easy to reason about. func ratio(a, b string) float64 { if a == b { return 1 } la, lb := len([]rune(a)), len([]rune(b)) m := la if lb > m { m = lb } if m == 0 { return 1 } return 1 - float64(levenshtein(a, b))/float64(m) } // damerau returns the optimal string alignment (Damerau-Levenshtein) distance, // where an adjacent transposition costs 1. Typos are very often transpositions // ("Гоглоя" vs "Гоголя"), so counting them as a single edit matters. func damerau(a, b string) int { ra, rb := []rune(a), []rune(b) la, lb := len(ra), len(rb) if la == 0 { return lb } if lb == 0 { return la } // Three rolling rows: d[i-2], d[i-1], d[i]. prev2 := make([]int, lb+1) prev := make([]int, lb+1) curr := make([]int, lb+1) for j := 0; j <= lb; j++ { prev[j] = j } for i := 1; i <= la; i++ { curr[0] = i for j := 1; j <= lb; j++ { cost := 1 if ra[i-1] == rb[j-1] { cost = 0 } best := min3(prev[j]+1, curr[j-1]+1, prev[j-1]+cost) if i > 1 && j > 1 && ra[i-1] == rb[j-2] && ra[i-2] == rb[j-1] { if t := prev2[j-2] + 1; t < best { best = t } } curr[j] = best } prev2, prev, curr = prev, curr, prev2 } return prev[lb] } func min3(a, b, c int) int { m := a if b < m { m = b } if c < m { m = c } return m } // maxTokenRunes is the longest token the fuzzy matcher will compare. Real // street-name tokens are far shorter; anything longer is noise or abuse. const maxTokenRunes = 64 // tokenScore scores two name tokens. A single edit is tolerated for tokens of // length >= 4 and two edits for length >= 8; otherwise it falls back to the // plain ratio. Short names (<= 3 chars) are intentionally not fuzzy-matched: // one edit is too large a fraction of the word to be safe. // // Tokens longer than maxTokenRunes are never similar to a real street name. // Rejecting them up front keeps a pathological input from turning one request // into millions of Levenshtein cell updates. func tokenScore(a, b string) float64 { if a == b { return 1 } ra, rb := utf8.RuneCountInString(a), utf8.RuneCountInString(b) if ra == 0 || rb == 0 || ra > maxTokenRunes || rb > maxTokenRunes { return 0 } d := levenshtein(a, b) minLen := ra if rb < minLen { minLen = rb } switch { case d == 1 && minLen >= 4: return 0.95 case d == 2 && minLen >= 8: return 0.90 default: return ratio(a, b) } } // nameSimilarity compares two canonical name keys (space-joined, sorted // tokens) in a token-aware, order-insensitive way. A typo in one word of a // multi-word name therefore does not sink the whole match, and reordered words // still score 1. func nameSimilarity(a, b string) float64 { if a == b { return 1 } ta, tb := strings.Fields(a), strings.Fields(b) if len(ta) == 0 || len(tb) == 0 { return 0 } sum, n := 0.0, 0 for _, x := range ta { best := 0.0 for _, y := range tb { if s := tokenScore(x, y); s > best { best = s } } sum += best n++ } for _, y := range tb { best := 0.0 for _, x := range ta { if s := tokenScore(x, y); s > best { best = s } } sum += best n++ } return sum / float64(n) }