Files
Maksim Totmin 17ff5c591f refactor!: make addrkit a multi-city address normalizer
- 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
2026-10-27 14:35:51 +07:00

420 lines
13 KiB
Go

// Package match resolves a ParsedAddress against a canonical registry index.
//
// It is the deterministic heart of addrkit. It knows nothing about storage,
// HTTP or LLMs: it operates on an in-memory index built from []address.Entry
// and always answers from that data. A wrong house number is never invented —
// when the exact house unit is not present the result is "partial", and when
// several streets are plausible the result is "ambiguous".
package match
import (
"sort"
"unicode/utf8"
"git.totmin.ru/en2zmax/addrkit/internal/domain/address"
)
// Candidate is one plausible match, returned alongside a non-"found" result so
// the caller (or an operator) can see what the resolver was choosing between.
type Candidate struct {
Address string `json:"address"`
City string `json:"city,omitempty"`
District string `json:"district,omitempty"`
House string `json:"house,omitempty"`
Structure string `json:"structure,omitempty"`
Score float64 `json:"score"`
}
// Result is the outcome of a resolution.
type Result struct {
Status address.Status `json:"status"`
Address string `json:"address,omitempty"`
City string `json:"city,omitempty"`
District string `json:"district,omitempty"`
Confidence float64 `json:"confidence"`
Candidates []Candidate `json:"candidates,omitempty"`
DataVersion string `json:"data_version,omitempty"`
}
// Options tunes matching behaviour.
type Options struct {
// FuzzyThreshold is the minimum similarity (0..1) for a street to be
// accepted as a typo-tolerant match. Below it the result is not_found.
FuzzyThreshold float64
// AmbiguityMargin: streets within this similarity of the best match make
// the result ambiguous instead of silently picking one.
AmbiguityMargin float64
// MaxCandidates caps the candidates returned to the caller.
MaxCandidates int
}
// DefaultOptions returns conservative defaults.
//
// The ambiguity margin is deliberately generous relative to the token scores:
// a one-edit match scores 0.95 and a two-edit match 0.90, so a 0.06 margin
// makes near-collisions (e.g. "Елоховская" vs "Волховская") ambiguous rather
// than silently picking the closer-but-wrong street.
func DefaultOptions() Options {
return Options{
FuzzyThreshold: 0.82,
AmbiguityMargin: 0.06,
MaxCandidates: 5,
}
}
type streetRef struct {
key string
cityKey string
ordinal string
nameKey string
}
// Index is an immutable, in-memory registry index. It is safe for concurrent
// reads once constructed.
type Index struct {
version string
opts Options
byKey map[string][]address.Entry
streets []streetRef
cities map[string]string // cityKey -> canonical display name
size int
}
// maxFuzzyNameRunes bounds the fuzzy scan. No real street name is longer than
// this; without the bound a single huge token would make every Levenshtein
// comparison walk a very long string and turn a request into a CPU sink.
const maxFuzzyNameRunes = 96
// NewIndex builds an index from canonical entries. Entries sharing a StreetKey
// are grouped; distinct streets are collected for typo-tolerant matching.
//
// Entries are sorted first, so candidate order is deterministic regardless of
// the order in which the storage adapter returned them.
func NewIndex(version string, entries []address.Entry, opts Options) *Index {
sorted := append([]address.Entry(nil), entries...)
sort.SliceStable(sorted, func(i, j int) bool { return entryLess(sorted[i], sorted[j]) })
ix := &Index{
version: version,
opts: opts,
byKey: make(map[string][]address.Entry, len(sorted)),
cities: make(map[string]string),
size: len(sorted),
}
seen := make(map[string]struct{}, len(sorted))
for _, e := range sorted {
ix.byKey[e.StreetKey] = append(ix.byKey[e.StreetKey], e)
if e.CityKey != "" {
if _, ok := ix.cities[e.CityKey]; !ok {
ix.cities[e.CityKey] = e.City
}
}
if _, ok := seen[e.StreetKey]; !ok {
seen[e.StreetKey] = struct{}{}
ix.streets = append(ix.streets, streetRef{
key: e.StreetKey,
cityKey: e.CityKey,
ordinal: e.Ordinal,
nameKey: e.NameKey,
})
}
}
sort.Slice(ix.streets, func(i, j int) bool { return ix.streets[i].key < ix.streets[j].key })
return ix
}
// entryLess defines the canonical order of registry rows. Address strings are
// the last meaningful tie-breaker; ID makes the order total even for duplicated
// rows.
func entryLess(a, b address.Entry) bool {
switch {
case a.CityKey != b.CityKey:
return a.CityKey < b.CityKey
case a.StreetKey != b.StreetKey:
return a.StreetKey < b.StreetKey
case a.House != b.House:
return a.House < b.House
case a.HouseFrac != b.HouseFrac:
return a.HouseFrac < b.HouseFrac
case a.HouseLetter != b.HouseLetter:
return a.HouseLetter < b.HouseLetter
case a.Building != b.Building:
return a.Building < b.Building
case a.BuildingFrac != b.BuildingFrac:
return a.BuildingFrac < b.BuildingFrac
case a.BuildingLetter != b.BuildingLetter:
return a.BuildingLetter < b.BuildingLetter
case a.Structure != b.Structure:
return a.Structure < b.Structure
case a.Address != b.Address:
return a.Address < b.Address
default:
return a.ID < b.ID
}
}
// Version returns the data version the index was built from.
func (ix *Index) Version() string { return ix.version }
// Size returns the number of indexed entries.
func (ix *Index) Size() int { return ix.size }
// Cities returns the distinct canonical city names present in the index, in
// deterministic order. A city-less index returns an empty slice.
func (ix *Index) Cities() []string {
out := make([]string, 0, len(ix.cities))
for _, name := range ix.cities {
out = append(out, name)
}
sort.Strings(out)
return out
}
// Resolve resolves a parsed address. It never returns an address that is not
// present in the index.
func (ix *Index) Resolve(p address.ParsedAddress) Result {
res := Result{DataVersion: ix.version, City: p.City}
entries := ix.byKey[p.StreetKey]
similarity := 1.0
if len(entries) == 0 {
var ambiguousKeys []string
entries, similarity, ambiguousKeys = ix.fuzzyStreets(p)
if len(ambiguousKeys) > 0 {
res.Status = address.StatusAmbiguous
res.Confidence = similarity
res.Candidates = ix.representatives(ambiguousKeys, similarity)
return res
}
}
if len(entries) == 0 {
res.Status = address.StatusNotFound
return res
}
if res.City == "" {
// A city-less query may still match exactly one city; report it so the
// caller does not have to re-derive the city from the candidates.
res.City = commonCity(entries)
}
if !p.HasHouse {
// Street is known but the caller did not supply a house: safe but
// incomplete.
res.Status = address.StatusPartial
res.Confidence = clamp01(similarity * 0.8)
res.Candidates = candidates(entries, similarity, ix.opts.MaxCandidates)
return res
}
matched := matchHouseUnit(entries, p)
switch len(matched) {
case 1:
res.Status = address.StatusFound
res.Address = matched[0].Address
res.City = matched[0].City
res.District = matched[0].District
res.Confidence = clamp01(similarity)
return res
case 0:
// Street recognised, exact house unit absent, or several units share
// the same number (ambiguous at the unit level).
if sameHouseSeveral(entries, p) {
res.Status = address.StatusAmbiguous
res.Confidence = clamp01(similarity * 0.9)
res.Candidates = candidates(matchHouseUnitAll(entries, p), similarity, ix.opts.MaxCandidates)
return res
}
res.Status = address.StatusPartial
res.Confidence = clamp01(similarity * 0.9)
// Prefer houses that share the number/fraction/letter: they are the
// plausible corrections (a different corpus or structure), while the
// rest of the street is noise.
related := matchHouseUnitAll(entries, p)
if len(related) == 0 {
related = entries
}
res.Candidates = candidates(related, similarity, ix.opts.MaxCandidates)
return res
default:
res.Status = address.StatusAmbiguous
res.Confidence = clamp01(similarity * 0.9)
res.Candidates = candidates(matched, similarity, ix.opts.MaxCandidates)
return res
}
}
// fuzzyStreets finds streets similar to the query. It returns the entries of
// the best street when the match is unambiguous, or the list of competing
// street keys when several are within the ambiguity margin.
func (ix *Index) fuzzyStreets(p address.ParsedAddress) (entries []address.Entry, similarity float64, ambiguousKeys []string) {
if p.NameKey == "" || utf8.RuneCountInString(p.NameKey) > maxFuzzyNameRunes {
return nil, 0, nil
}
best := 0.0
bestKey := ""
for _, s := range ix.streets {
// A query that names a city may only match streets in that city.
if p.CityKey != "" && s.cityKey != p.CityKey {
continue
}
// If the query carries an ordinal, the candidate's ordinal must match:
// a user who says "1-й Северный" does not mean the plain "Северная".
if !ordinalCompatible(p.Ordinal, s.ordinal) {
continue
}
if sim := nameSimilarity(p.NameKey, s.nameKey); sim > best {
best = sim
bestKey = s.key
}
}
if best < ix.opts.FuzzyThreshold {
return nil, best, nil
}
for _, s := range ix.streets {
if p.CityKey != "" && s.cityKey != p.CityKey {
continue
}
if !ordinalCompatible(p.Ordinal, s.ordinal) {
continue
}
if nameSimilarity(p.NameKey, s.nameKey) >= best-ix.opts.AmbiguityMargin {
ambiguousKeys = append(ambiguousKeys, s.key)
}
}
if len(ambiguousKeys) > 1 {
return nil, best, ambiguousKeys
}
return ix.byKey[bestKey], best, nil
}
// ordinalCompatible reports whether a candidate street may compete with the
// query given the query's ordinal. When the query specifies an ordinal, only
// candidates with the same ordinal are allowed; otherwise any candidate is.
func ordinalCompatible(queryOrdinal, candidateOrdinal string) bool {
if queryOrdinal == "" {
return true
}
return queryOrdinal == candidateOrdinal
}
// representatives returns one candidate per competing street, which is far
// more useful to a user than five houses of a single street.
func (ix *Index) representatives(keys []string, score float64) []Candidate {
sort.Strings(keys)
out := make([]Candidate, 0, len(keys))
for _, k := range keys {
es := ix.byKey[k]
if len(es) == 0 {
continue
}
e := es[0]
out = append(out, Candidate{
Address: e.Address,
City: e.City,
District: e.District,
House: e.House,
Structure: e.Structure,
Score: clamp01(score),
})
if len(out) == ix.opts.MaxCandidates {
break
}
}
return out
}
// matchHouseUnit returns entries whose house unit equals p's, exactly.
func matchHouseUnit(entries []address.Entry, p address.ParsedAddress) []address.Entry {
var out []address.Entry
for _, e := range entries {
if houseUnitEqual(e, p) {
out = append(out, e)
}
}
return out
}
// matchHouseUnitAll returns all entries for entries that share the same house
// number as p (used to surface ambiguity).
func matchHouseUnitAll(entries []address.Entry, p address.ParsedAddress) []address.Entry {
var out []address.Entry
for _, e := range entries {
if e.House == p.House && e.HouseFrac == p.HouseFrac && e.HouseLetter == p.HouseLetter {
out = append(out, e)
}
}
return out
}
// sameHouseSeveral reports whether more than one entry matches the house
// number but with differing buildings/letters, i.e. the unit is ambiguous.
func sameHouseSeveral(entries []address.Entry, p address.ParsedAddress) bool {
return len(matchHouseUnitAll(entries, p)) > 1
}
// houseUnitEqual compares the parts of a house unit. Unspecified (empty) parts
// must also be empty on the entry side, so "60/1" never matches "60/1 к148".
// The structure ("стр. 2") is part of the identity: a caller who explicitly
// asked for a different structure must not be handed another building.
func houseUnitEqual(e address.Entry, p address.ParsedAddress) bool {
return e.House == p.House &&
e.HouseFrac == p.HouseFrac &&
e.HouseLetter == p.HouseLetter &&
e.Building == p.Building &&
e.BuildingFrac == p.BuildingFrac &&
e.BuildingLetter == p.BuildingLetter &&
e.Structure == p.Structure
}
func candidates(entries []address.Entry, score float64, limit int) []Candidate {
if limit <= 0 || len(entries) == 0 {
return nil
}
out := make([]Candidate, 0, len(entries))
for _, e := range entries {
out = append(out, Candidate{
Address: e.Address,
City: e.City,
District: e.District,
House: e.House,
Structure: e.Structure,
Score: clamp01(score),
})
if len(out) == limit {
break
}
}
return out
}
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
// commonCity returns the city shared by all entries, or "" when they span
// cities (or carry no city at all).
func commonCity(entries []address.Entry) string {
key, name := "", ""
for _, e := range entries {
if e.CityKey == "" {
continue
}
if key == "" {
key, name = e.CityKey, e.City
continue
}
if e.CityKey != key {
return ""
}
}
return name
}