- remove the events/outages module, ArcGIS adapter, portal config and event statuses - add a configurable city dictionary/parser and make City part of StreetKey - seed: read city/city_name, namespace IDs by city, keep structures distinct - fix reliability: schema v2 with fail-closed load, deterministic ordering, input limits, config validation, atomic DB publish, LLM response caps - add read-only shadow mode comparing the old ES path with addrkit - breaking: registry.db must be re-seeded; config has no portal section
178 lines
4.0 KiB
Go
178 lines
4.0 KiB
Go
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)
|
|
}
|