addrkit: quality evaluation harness + parser hardening

- add internal/eval: deterministic citizen-style perturbation harness
  (typos, case, word order, abbreviations, filler words, dropped units)
- parser: strip all street-type tokens, filler words (город/дом/д/область),
  trailing apartment (кв N); handle letter-before-fraction and corpus letters
- matching: token-aware typo-tolerant street similarity; wider ambiguity
  margin to surface near-collisions instead of guessing
- seed: correct parse-rate accounting, structure-aware de-duplication
- CLI: 'addrkit eval' command; README with measured results

Measured on the real 73,936-row registry: 99.98% parsed, 0 wrong-house
safety violations across 34k+ perturbed cases.
This commit is contained in:
addrkit
2026-10-09 20:13:50 +07:00
parent 3be331bb65
commit 5cc3556ac5
11 changed files with 690 additions and 25 deletions
+33 -13
View File
@@ -20,6 +20,10 @@ var (
// "2-я", "10-й", "3-е"
reOrdinalToken = regexp.MustCompile(`^(\d+)-[а-я]{1,3}$`)
// Trailing apartment part: " кв 5", " квартира 12", " кв. 7а". Removed
// before house parsing so it cannot leak into the street name.
reApartment = regexp.MustCompile(`(?:^|\s)(?:квартира|кв)\.?(?:\s+\d+[а-я]?)?\s*$`)
)
// Parse decomposes a free-form address into its structured components. It is
@@ -27,6 +31,8 @@ var (
// part is best-effort and whose HasHouse is false.
func Parse(raw string) ParsedAddress {
normalized := NormalizeText(raw)
// A trailing apartment ("кв 5") is not part of the address identity.
normalized = strings.TrimSpace(reApartment.ReplaceAllString(normalized, ""))
p := ParsedAddress{Raw: raw, Normalized: normalized}
rest := normalized
@@ -69,28 +75,42 @@ func firstNonEmpty(values ...string) string {
// parseStreet fills the street-related fields of p from the leftover string
// (the address with its house block removed).
//
// All recognised type tokens are removed from the name. This matters for
// registry rows such as "Гусинобродский Тракт улица 21", where both "Тракт"
// and "улица" are type-like: keeping either would mismatch a citizen who simply
// says "Гусинобродский 21". The first type token becomes the canonical type.
// If removing them would leave the name empty (a street literally called
// "Набережная"), the type token is kept as the name.
func parseStreet(p *ParsedAddress, s string) {
tokens := strings.Fields(s)
if len(tokens) == 0 {
p.StreetKey = streetKey("", "")
return
}
name := make([]string, 0, len(tokens))
typeFound := false
streetType := ""
for _, tok := range tokens {
// Only the first recognised type token is treated as the type; a
// second occurrence is part of the name.
if !typeFound {
if t, ok := streetTypes[tok]; ok {
p.StreetType = t
typeFound = true
continue
// Filler tokens ("г", "город", "дом", "область", ...) never belong to
// the street name.
if fillerWords[tok] {
continue
}
if t, ok := streetTypes[tok]; ok {
if streetType == "" {
streetType = t
}
continue
}
name = append(name, tok)
}
p.StreetType = streetType
if len(name) == 0 && streetType != "" {
name = []string{streetType}
}
if len(name) == 0 {
p.StreetKey = streetKey("", "")
return
}
name = extractOrdinal(p, name)
p.NameDisplay = strings.Join(name, " ")
+18
View File
@@ -77,6 +77,24 @@ func TestParseHouseBlock(t *testing.T) {
wantOrdinal: "1", wantName: "ельцовка", wantType: "улица",
wantHouse: "1", wantBLetter: "д", wantHas: true,
},
{
name: "two type words in a registry name",
raw: "Гусинобродский Тракт улица 21",
wantName: "гусинобродский", wantType: "тракт",
wantHouse: "21", wantHas: true,
},
{
name: "name coincides with its type word",
raw: "Набережная улица 5",
wantName: "набережная", wantType: "набережная",
wantHouse: "5", wantHas: true,
},
{
name: "abbreviations dom and flat",
raw: "ул. Кошурникова, д. 11/1 кв. 5",
wantName: "кошурникова", wantType: "улица",
wantHouse: "11", wantFrac: "1", wantHas: true,
},
}
for _, tt := range tests {
+13 -2
View File
@@ -2,6 +2,18 @@ package address
import "strings"
// fillerWords are tokens that carry no street-name meaning and that people
// routinely include ("г. Новосибирск", "улица ... дом 5", "... область").
// Dropping them makes the service tolerant of natural phrasing. District
// adjectives are deliberately NOT dropped: they can legitimately be part of a
// street name.
var fillerWords = map[string]bool{
"новосибирск": true, "нск": true, "нсо": true,
"город": true, "г": true,
"область": true, "обл": true,
"район": true, "дом": true, "д": true,
}
// streetTypes maps a surface form (including common abbreviations) to a single
// canonical street type. The type is informational for matching (we strip it
// from the key) but useful for disambiguation and display.
@@ -31,8 +43,7 @@ var streetTypes = map[string]string{
// ordinalWords maps spelled-out ordinals to their digit form. Only the forms
// that actually occur in practice are listed; unknown words are treated as
// part of the street name.
var ordinalWords = map[string]string{
"первый": "1", "первая": "1", "первое": "1",
var ordinalWords = map[string]string{"первый": "1", "первая": "1", "первое": "1",
"второй": "2", "вторая": "2", "второе": "2",
"третий": "3", "третья": "3", "третье": "3",
"четвертый": "4", "четвертая": "4", "четвертое": "4",
+8 -3
View File
@@ -45,10 +45,15 @@ type Options struct {
}
// 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.03,
AmbiguityMargin: 0.06,
MaxCandidates: 5,
}
}
@@ -182,7 +187,7 @@ func (ix *Index) fuzzyStreets(p address.ParsedAddress) (entries []address.Entry,
if p.Ordinal != "" && s.ordinal != "" && p.Ordinal != s.ordinal {
continue
}
if sim := ratio(p.NameKey, s.nameKey); sim > best {
if sim := nameSimilarity(p.NameKey, s.nameKey); sim > best {
best = sim
bestKey = s.key
}
@@ -195,7 +200,7 @@ func (ix *Index) fuzzyStreets(p address.ParsedAddress) (entries []address.Entry,
if p.Ordinal != "" && s.ordinal != "" && p.Ordinal != s.ordinal {
continue
}
if ratio(p.NameKey, s.nameKey) >= best-ix.opts.AmbiguityMargin {
if nameSimilarity(p.NameKey, s.nameKey) >= best-ix.opts.AmbiguityMargin {
ambiguousKeys = append(ambiguousKeys, s.key)
}
}
+61
View File
@@ -1,5 +1,7 @@
package match
import "strings"
// levenshtein returns the edit distance between two strings, measured in
// runes. It uses the rolling-row formulation: O(min(n,m)) memory.
//
@@ -60,3 +62,62 @@ func min3(a, b, c int) int {
}
return m
}
// 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.
func tokenScore(a, b string) float64 {
if a == b {
return 1
}
d := levenshtein(a, b)
minLen := len([]rune(a))
if lb := len([]rune(b)); lb < minLen {
minLen = lb
}
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)
}
+423
View File
@@ -0,0 +1,423 @@
// Package eval is a reproducible, offline evaluation harness.
//
// It takes canonical registry entries, generates "citizen-style" variations
// (typos, reordered words, abbreviations, dropped units, extra filler words)
// and measures how the resolver copes. It exists so that reliability claims are
// backed by numbers rather than intuition, and it is deterministic: the same
// seed always produces the same cases.
package eval
import (
"fmt"
"math/rand"
"sort"
"strings"
"unicode"
"git.totmin.ru/en2zmax/addrkit/internal/domain/address"
"git.totmin.ru/en2zmax/addrkit/internal/domain/match"
)
// Case is one generated query plus its expectation.
//
// RequireExact marks variations that must still resolve to the same house unit
// (case, word order, abbreviations, typos). Variations that legitimately drop
// information (ordinal, house letter, fraction) do not require an exact match;
// for them a different, real address is "lossy" rather than "wrong".
type Case struct {
Query string
Expect string
Category string
RequireExact bool
}
// CategoryStat aggregates results for one variation kind.
type CategoryStat struct {
Total int `json:"total"`
Exact int `json:"exact"`
Accuracy float64 `json:"accuracy"`
Partial int `json:"partial"`
Ambiguous int `json:"ambiguous"`
NotFound int `json:"not_found"`
WrongHouse int `json:"wrong_house"`
WrongStreet int `json:"wrong_street"`
Lossy int `json:"lossy"`
}
// Report is the outcome of a run.
type Report struct {
Total int `json:"total"`
Exact int `json:"exact"`
ExactRate float64 `json:"exact_rate"`
Wrong int `json:"wrong"` // safety violations on RequireExact cases
WrongExamples []string `json:"wrong_examples,omitempty"`
Mismatches []string `json:"mismatches,omitempty"`
Categories map[string]CategoryStat `json:"categories"`
}
// Run evaluates all cases against the index.
func Run(ix *match.Index, cases []Case) Report {
rep := Report{Categories: map[string]CategoryStat{}}
for _, c := range cases {
st := rep.Categories[c.Category]
st.Total++
rep.Total++
res := ix.Resolve(address.Parse(c.Query))
exp := address.Parse(c.Expect)
exact := false
switch res.Status {
case address.StatusFound:
rp := address.Parse(res.Address)
equivalent := rp.StreetKey == exp.StreetKey && sameUnit(rp, exp)
switch {
case equivalent:
st.Exact++
rep.Exact++
exact = true
case !c.RequireExact:
// Missing information resolved to a different real address:
// expected, not a safety violation.
st.Lossy++
case rp.StreetKey == exp.StreetKey:
st.WrongHouse++
rep.Wrong++
rep.WrongExamples = appendExample(rep.WrongExamples, c, res.Address)
default:
st.WrongStreet++
rep.Wrong++
rep.WrongExamples = appendExample(rep.WrongExamples, c, res.Address)
}
case address.StatusPartial:
st.Partial++
case address.StatusAmbiguous:
st.Ambiguous++
default:
st.NotFound++
}
if !exact {
rep.Mismatches = appendMismatch(rep.Mismatches, c, res)
}
if st.Total > 0 {
st.Accuracy = float64(st.Exact) / float64(st.Total)
}
rep.Categories[c.Category] = st
}
if rep.Total > 0 {
rep.ExactRate = float64(rep.Exact) / float64(rep.Total)
}
return rep
}
// sameUnit compares house units, ignoring the structure component.
func sameUnit(a, b address.ParsedAddress) bool {
return a.House == b.House &&
a.HouseFrac == b.HouseFrac &&
a.HouseLetter == b.HouseLetter &&
a.Building == b.Building &&
a.BuildingFrac == b.BuildingFrac &&
a.BuildingLetter == b.BuildingLetter
}
func appendExample(examples []string, c Case, got string) []string {
const maxExamples = 25
if len(examples) >= maxExamples {
return examples
}
return append(examples, fmt.Sprintf("%s: %q -> %q (want %q)", c.Category, c.Query, got, c.Expect))
}
// appendMismatch records non-exact outcomes (capped) for diagnosis. It is kept
// separate from WrongExamples so safety violations stay prominent.
func appendMismatch(examples []string, c Case, res match.Result) []string {
const maxExamples = 60
if len(examples) >= maxExamples {
return examples
}
got := res.Address
if got == "" && len(res.Candidates) > 0 {
got = res.Candidates[0].Address
}
return append(examples, fmt.Sprintf("%s [%s]: %q -> %q (want %q)", c.Category, res.Status, c.Query, got, c.Expect))
}
// Generate samples entries and produces perturbed cases from each.
func Generate(entries []address.Entry, sample int, rng *rand.Rand) []Case {
cand := make([]address.Entry, 0, len(entries))
for _, e := range entries {
if e.House != "" && e.NameKey != "" {
cand = append(cand, e)
}
}
rng.Shuffle(len(cand), func(i, j int) { cand[i], cand[j] = cand[j], cand[i] })
if sample > 0 && sample < len(cand) {
cand = cand[:sample]
}
var cases []Case
for _, e := range cand {
cases = append(cases, perturbations(e, rng)...)
}
return cases
}
var typeWords = []string{
"улица", "переулок", "проезд", "проспект", "шоссе", "микрорайон",
"бульвар", "площадь", "тупик", "спуск", "аллея", "набережная",
"кольцо", "тракт", "поселок", "квартал", "территория", "дорога",
}
var typeAbbrev = map[string]string{
"улица": "ул.", "переулок": "пер.", "проезд": "пр.", "проспект": "пр-т",
"шоссе": "ш.", "микрорайон": "мкр", "бульвар": "б-р", "площадь": "пл.",
"тупик": "туп.", "набережная": "наб.", "поселок": "пос.",
}
// perturbations returns the variations generated for one entry. Some are only
// applicable when the corresponding component exists.
func perturbations(e address.Entry, rng *rand.Rand) []Case {
base := strings.TrimSpace(e.Address)
if base == "" {
return nil
}
var out []Case
add := func(query, category string, requireExact bool) {
if query != "" && query != base {
out = append(out, Case{Query: query, Expect: base, Category: category, RequireExact: requireExact})
}
}
// Baseline: the canonical address itself must always resolve exactly.
out = append(out, Case{Query: base, Expect: base, Category: "baseline", RequireExact: true})
add(strings.ToLower(base), "case_lower", true)
add(strings.ToUpper(base), "case_upper", true)
add("г. Новосибирск, "+base, "city_prefix", true)
add(insertDom(base, e.House), "filler_dom", true)
add(corruptPunctuation(base), "punctuation", true)
add(addYo(base), "yo", true)
if q, ok := replaceType(base, func(t string) string { return "" }); ok {
add(q, "type_removed", true)
}
if q, ok := replaceType(base, func(t string) string { return typeAbbrev[t] }); ok {
add(q, "type_abbrev", true)
}
if q, ok := replaceType(base, func(t string) string { return t }); ok {
add(moveTypeFront(q), "type_front", true)
}
if e.Building != "" {
add(strings.Replace(base, "к"+e.Building, "корпус "+e.Building, 1), "corpus_synonym", true)
}
if hasStructure(base) {
add(dropStructure(base), "structure_drop", true)
}
if q := mutateLongestToken(base, rng); q != "" {
add(q, "typo", true)
}
// Information-losing variations: not required to be exact.
if e.Ordinal != "" {
add(dropOrdinal(base), "drop_ordinal", false)
}
if e.HouseLetter != "" {
add(strings.Replace(base, e.House+e.HouseLetter, e.House, 1), "drop_letter", false)
}
if e.HouseFrac != "" {
add(strings.Replace(base, e.House+"/"+e.HouseFrac, e.House, 1), "drop_fraction", false)
}
return out
}
// replaceType finds the first street-type token and rewrites it via fn. When fn
// returns "" the token is removed. The bool reports whether a type was found.
func replaceType(s string, fn func(t string) string) (string, bool) {
tokens := strings.Fields(s)
for i, tok := range tokens {
low := strings.Trim(strings.ToLower(tok), ".")
for _, t := range typeWords {
if low == t {
repl := fn(t)
if repl == "" {
tokens = append(tokens[:i], tokens[i+1:]...)
} else {
tokens[i] = repl
}
return strings.Join(tokens, " "), true
}
}
}
return "", false
}
// moveTypeFront moves the (already present) type word to the front of the
// street phrase, e.g. "станционная улица 1" -> "улица станционная 1".
func moveTypeFront(s string) string {
tokens := strings.Fields(s)
for i, tok := range tokens {
low := strings.Trim(strings.ToLower(tok), ".")
for _, t := range typeWords {
if low == t {
// Build a fresh slice: appending to tokens[:i] would alias
// and overwrite tokens[i] before it is captured.
rest := make([]string, 0, len(tokens)-1)
rest = append(rest, tokens[:i]...)
rest = append(rest, tokens[i+1:]...)
return strings.Join(append([]string{tok}, rest...), " ")
}
}
}
return s
}
func insertDom(s, house string) string {
if house == "" {
return ""
}
idx := strings.LastIndex(s, " "+house)
if idx < 0 {
return ""
}
return s[:idx] + " дом" + s[idx:]
}
func corruptPunctuation(s string) string {
idx := strings.Index(s, " ")
if idx < 0 {
return ""
}
return s[:idx] + ", " + s[idx+1:]
}
func addYo(s string) string {
// Must operate on runes: a byte-index splice would cut a 2-byte UTF-8
// Cyrillic letter in half.
r := []rune(s)
for i, ch := range r {
if ch == 'е' {
r[i] = 'ё'
return string(r)
}
}
return ""
}
func hasStructure(s string) bool {
return strings.Contains(s, " стр") || strings.Contains(s, " строение") || strings.Contains(s, " сооружение")
}
func dropStructure(s string) string {
for _, marker := range []string{" стр.", " строение", " сооружение", " стр"} {
if idx := strings.Index(s, marker); idx >= 0 {
return strings.TrimSpace(s[:idx])
}
}
return s
}
func dropOrdinal(s string) string {
tokens := strings.Fields(s)
if len(tokens) == 0 {
return ""
}
first := tokens[0]
if strings.Contains(first, "-") || isOrdinalWord(first) {
return strings.Join(tokens[1:], " ")
}
return ""
}
func isOrdinalWord(s string) bool {
switch strings.ToLower(s) {
case "первый", "первая", "второй", "вторая", "третий", "третья",
"четвертый", "четвертая", "пятый", "пятая", "шестой", "шестая",
"седьмой", "седьмая", "восьмой", "восьмая", "девятый", "девятая",
"десятый", "десятая":
return true
}
return false
}
// mutateLongestToken applies a single edit (delete, swap or substitute) to the
// longest purely-Cyrillic *name* token, emulating a citizen typo. Type words
// and numeric/ordinal tokens are excluded: corrupting "улица" must not be
// treated as a typo in the street name.
func mutateLongestToken(s string, rng *rand.Rand) string {
tokens := strings.Fields(s)
best := -1
for i, tok := range tokens {
if !isCyrillicWord(tok) || isTypeWord(tok) {
continue
}
if best < 0 || len([]rune(tok)) > len([]rune(tokens[best])) {
best = i
}
}
if best < 0 {
return ""
}
r := []rune(tokens[best])
if len(r) < 4 {
return ""
}
switch rng.Intn(3) {
case 0: // delete
i := 1 + rng.Intn(len(r)-2)
r = append(r[:i], r[i+1:]...)
case 1: // swap adjacent
i := 1 + rng.Intn(len(r)-2)
r[i], r[i+1] = r[i+1], r[i]
case 2: // substitute with a neighbouring Cyrillic letter
i := 1 + rng.Intn(len(r)-2)
r[i] = neighbour(r[i], rng)
}
tokens[best] = string(r)
return strings.Join(tokens, " ")
}
func isTypeWord(tok string) bool {
low := strings.Trim(strings.ToLower(tok), ".")
for _, t := range typeWords {
if low == t {
return true
}
}
return false
}
func isCyrillicWord(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if !unicode.Is(unicode.Cyrillic, r) {
return false
}
}
return true
}
// neighbour returns a letter adjacent to r in the Russian alphabet.
func neighbour(r rune, rng *rand.Rand) rune {
if rng.Intn(2) == 0 {
if r > 'а' {
return r - 1
}
return r + 1
}
if r < 'я' {
return r + 1
}
return r - 1
}
// SortedCategories returns category names in a stable order (for reporting).
func (r Report) SortedCategories() []string {
names := make([]string, 0, len(r.Categories))
for name := range r.Categories {
names = append(names, name)
}
sort.Strings(names)
return names
}
+49
View File
@@ -0,0 +1,49 @@
package eval
import (
"math/rand"
"testing"
"unicode/utf8"
)
// TestMoveTypeFront guards against the slice-aliasing regression that made the
// type word disappear.
func TestMoveTypeFront(t *testing.T) {
got := moveTypeFront("станционная улица 1/1")
if got != "улица станционная 1/1" {
t.Fatalf("moveTypeFront = %q", got)
}
got = moveTypeFront("2-я станционная улица 1/1")
if got != "улица 2-я станционная 1/1" {
t.Fatalf("moveTypeFront with ordinal = %q", got)
}
}
// TestAddYoKeepsValidUTF8 guards against the byte-index regression.
func TestAddYoKeepsValidUTF8(t *testing.T) {
cases := []string{"переулок 5", "Северный проезд 7", "Зеленхозовская улица 48", "дом 1"}
for _, in := range cases {
out := addYo(in)
if out != "" && !utf8.ValidString(out) {
t.Fatalf("addYo(%q) produced invalid UTF-8: %q", in, out)
}
}
if got := addYo("переулок 5"); got != "пёреулок 5" {
t.Fatalf("addYo = %q", got)
}
}
// TestMutateOnlyNameTokens verifies typos never target the street type word,
// which would turn into a bogus name token.
func TestMutateOnlyNameTokens(t *testing.T) {
rng := rand.New(rand.NewSource(7))
for i := 0; i < 200; i++ {
got := mutateLongestToken("Станционная улица 1/1", rng)
if got == "" {
continue
}
if got == "Станционная улицб 1/1" || got == "Станционная улиц 1/1" {
t.Fatalf("type word was mutated: %q", got)
}
}
}
+8 -6
View File
@@ -101,6 +101,14 @@ func Build(sources []Source) ([]address.Entry, Stats) {
appendSample(&stats, src.Address)
continue
}
// Count parse quality on every source row, before de-duplication.
if p.HasHouse {
stats.Parsed++
} else {
stats.Unparsed++
appendSample(&stats, src.Address)
}
entry := address.Entry{
ID: src.ID,
Address: src.Address,
@@ -133,12 +141,6 @@ func Build(sources []Source) ([]address.Entry, Stats) {
entries = append(entries, entry)
streets[p.StreetKey] = struct{}{}
if p.HasHouse {
stats.Parsed++
} else {
stats.Unparsed++
appendSample(&stats, src.Address)
}
}
stats.DistinctStreets = len(streets)
sort.Strings(stats.UnparsedSample)