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
+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)
}
}
}