addrkit: deterministic address resolution service (Go)

- domain: address normalization/parsing, matching with explicit statuses
- adapters: SQLite registry store, ArcGIS outages client, optional LLM rewriter, HTTP API
- seed pipeline with duplicate handling and unparsed reporting
- CLI (seed/verify/resolve), systemd unit, OpenAPI contract, tests
This commit is contained in:
addrkit
2026-10-09 20:05:32 +07:00
commit 3be331bb65
34 changed files with 3446 additions and 0 deletions
+299
View File
@@ -0,0 +1,299 @@
// 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.
func DefaultOptions() Options {
return Options{
FuzzyThreshold: 0.82,
AmbiguityMargin: 0.03,
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 := ratio(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 ratio(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
}
+137
View File
@@ -0,0 +1,137 @@
package match
import (
"testing"
"git.totmin.ru/en2zmax/addrkit/internal/domain/address"
)
// buildIndex parses canonical addresses into entries, mirroring what the seed
// pipeline does, so tests exercise the real code path end to end.
func buildIndex(t *testing.T, addresses ...string) *Index {
t.Helper()
entries := make([]address.Entry, 0, len(addresses))
for i, a := range addresses {
p := address.Parse(a)
entries = append(entries, address.Entry{
ID: a, Address: a, District: "Тестовый",
Ordinal: p.Ordinal, NameKey: p.NameKey, StreetType: p.StreetType, StreetKey: p.StreetKey,
House: p.House, HouseFrac: p.HouseFrac, HouseLetter: p.HouseLetter,
Building: p.Building, BuildingFrac: p.BuildingFrac, BuildingLetter: p.BuildingLetter,
Structure: p.Structure,
})
_ = i
}
return NewIndex("test", entries, DefaultOptions())
}
func TestResolveStatuses(t *testing.T) {
ix := buildIndex(t,
"Станционная улица 1/1",
"Станционная улица 2/1",
"Станционная улица 3/1 к1",
"Станционная улица 3/1 к2",
"Искитимская улица 5",
)
tests := []struct {
name string
query string
wantStatus address.Status
wantAddr string
}{
{
name: "found with ordinal, fraction and structure noise",
query: "2-я Станционная улица 1/1 стр.",
wantStatus: address.StatusFound,
wantAddr: "Станционная улица 1/1",
},
{
name: "found exact house",
query: "станционная 2/1",
wantStatus: address.StatusFound,
wantAddr: "Станционная улица 2/1",
},
{
name: "found with corpus",
query: "Станционная улица 3/1 корпус 1",
wantStatus: address.StatusFound,
wantAddr: "Станционная улица 3/1 к1",
},
{
name: "typo in street name tolerated",
query: "станционая 1/1",
wantStatus: address.StatusFound,
wantAddr: "Станционная улица 1/1",
},
{
name: "missing house is partial",
query: "станционная улица",
wantStatus: address.StatusPartial,
},
{
name: "wrong house fraction is partial",
query: "станционная 1/2",
wantStatus: address.StatusPartial,
},
{
name: "ambiguous building",
query: "станционная 3/1",
wantStatus: address.StatusAmbiguous,
},
{
name: "unknown street",
query: "ленина 5",
wantStatus: address.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res := ix.Resolve(address.Parse(tt.query))
if res.Status != tt.wantStatus {
t.Fatalf("status = %q, want %q (candidates: %+v)", res.Status, tt.wantStatus, res.Candidates)
}
if tt.wantAddr != "" && res.Address != tt.wantAddr {
t.Errorf("address = %q, want %q", res.Address, tt.wantAddr)
}
if res.DataVersion != "test" {
t.Errorf("data version = %q, want test", res.DataVersion)
}
})
}
}
// TestResolveNeverInventsAddress is the central safety property: whenever the
// status is "found", the returned address must exist in the index.
func TestResolveNeverInventsAddress(t *testing.T) {
addresses := []string{
"Станционная улица 1/1",
"Станционная улица 2/1",
"Искитимская улица 5",
}
ix := buildIndex(t, addresses...)
known := make(map[string]bool, len(addresses))
for _, a := range addresses {
known[a] = true
}
queries := []string{
"2-я Станционная 1/1", "станционная 2/1 к9", "искитимская 5", "искитимская 5/1",
"несуществующая 99", "станционная 1/1 стр.3", " ",
}
for _, q := range queries {
res := ix.Resolve(address.Parse(q))
if res.Status == address.StatusFound && !known[res.Address] {
t.Fatalf("invented address %q for query %q", res.Address, q)
}
}
}
func TestRatio(t *testing.T) {
if got := ratio("станционная", "станционная"); got != 1 {
t.Errorf("identical strings ratio = %v, want 1", got)
}
if got := ratio("станционная", "искитимская"); got > 0.6 {
t.Errorf("unrelated strings ratio = %v, want < 0.6", got)
}
}
+62
View File
@@ -0,0 +1,62 @@
package match
// 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)
}
func min3(a, b, c int) int {
m := a
if b < m {
m = b
}
if c < m {
m = c
}
return m
}
+89
View File
@@ -0,0 +1,89 @@
package match
import (
"context"
"git.totmin.ru/en2zmax/addrkit/internal/domain/address"
)
// Rewriter is an optional, pluggable query rewriter. Its only job is to
// propose alternative surface forms of a user address (for example an LLM
// normalising "кошурникова одиннадцать дробь один" to "Кошевого 11/1"). The
// rewriter never decides the answer: every proposal is parsed and resolved
// deterministically, and only entries that exist in the index can be returned.
//
// It is defined here (consumer side) so the domain stays free of any transport
// or vendor dependency.
type Rewriter interface {
Rewrite(ctx context.Context, raw string) ([]string, error)
}
// Resolver wraps an Index with an optional Rewriter.
//
// The deterministic path is always tried first. The rewriter is consulted only
// when the deterministic result is not a confident "found", and its
// suggestions can only ever turn a miss into a match against real data.
type Resolver struct {
index *Index
rewriter Rewriter
}
// NewResolver builds a resolver. rewriter may be nil to disable rewriting.
func NewResolver(index *Index, rewriter Rewriter) *Resolver {
return &Resolver{index: index, rewriter: rewriter}
}
// Version returns the current data version.
func (r *Resolver) Version() string { return r.index.Version() }
// Ready reports whether the resolver has a usable index.
func (r *Resolver) Ready() bool { return r.index != nil && r.index.Size() > 0 }
// Resolve parses raw and resolves it, consulting the rewriter when enabled.
func (r *Resolver) Resolve(ctx context.Context, raw string) Result {
parsed := address.Parse(raw)
res := r.index.Resolve(parsed)
if res.Status == address.StatusFound || r.rewriter == nil {
return res
}
if res.Status != address.StatusNotFound && res.Status != address.StatusAmbiguous {
return res
}
alternatives, err := r.rewriter.Rewrite(ctx, raw)
if err != nil || len(alternatives) == 0 {
return res
}
best := res
for _, alt := range alternatives {
candidate := r.index.Resolve(address.Parse(alt))
if better(candidate, best) {
best = candidate
}
}
return best
}
// better ranks results: a stronger status wins, then higher confidence.
func better(a, b Result) bool {
ra, rb := statusRank(a.Status), statusRank(b.Status)
if ra != rb {
return ra > rb
}
return a.Confidence > b.Confidence
}
func statusRank(s address.Status) int {
switch s {
case address.StatusFound:
return 3
case address.StatusAmbiguous:
return 2
case address.StatusPartial:
return 1
default:
return 0
}
}