// 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" "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"` District string `json:"district,omitempty"` House string `json:"house,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"` 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 ordinal string nameKey string streetType 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 } // NewIndex builds an index from canonical entries. Entries sharing a StreetKey // are grouped; distinct streets are collected for typo-tolerant matching. func NewIndex(version string, entries []address.Entry, opts Options) *Index { ix := &Index{ version: version, opts: opts, byKey: make(map[string][]address.Entry, len(entries)), } seen := make(map[string]struct{}, len(entries)) for _, e := range entries { ix.byKey[e.StreetKey] = append(ix.byKey[e.StreetKey], e) if _, ok := seen[e.StreetKey]; !ok { seen[e.StreetKey] = struct{}{} ix.streets = append(ix.streets, streetRef{ key: e.StreetKey, ordinal: e.Ordinal, nameKey: e.NameKey, streetType: e.StreetType, }) } } sort.Slice(ix.streets, func(i, j int) bool { return ix.streets[i].key < ix.streets[j].key }) return ix } // 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 { n := 0 for _, v := range ix.byKey { n += len(v) } return n } // 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} 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 !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.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) res.Candidates = candidates(entries, 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 == "" { return nil, 0, nil } best := 0.0 bestKey := "" for _, s := range ix.streets { // If both sides carry an ordinal and they differ, the streets are // different even if the names rhyme. if p.Ordinal != "" && s.ordinal != "" && 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.Ordinal != "" && s.ordinal != "" && 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 } // 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, District: e.District, House: e.House, 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". 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 } 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, District: e.District, House: e.House, 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 }