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:
@@ -115,3 +115,46 @@ ADDRKIT_REGISTRY=/path/to/address_registry.ndjson \
|
|||||||
|
|
||||||
На дампе из 73 936 строк парсер распознаёт **99.98 %** (14 строк — заведомо
|
На дампе из 73 936 строк парсер распознаёт **99.98 %** (14 строк — заведомо
|
||||||
битые форматы; они не угадываются, а попадают в отчёт `stats.unparsed_sample`).
|
битые форматы; они не угадываются, а попадают в отчёт `stats.unparsed_sample`).
|
||||||
|
|
||||||
|
## Оценка качества (`addrkit eval`)
|
||||||
|
|
||||||
|
Детерминированный harness генерирует «гражданские» искажения канонических
|
||||||
|
адресов и измеряет, как резолвер справляется. Это offline: сеть не нужна.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/addrkit eval -db registry.db -sample 800 -seed 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Категории и результат на реальном реестре (по 800 адресам на seed):
|
||||||
|
|
||||||
|
| Категория | Пример искажения | Точность |
|
||||||
|
|---|---|---|
|
||||||
|
| baseline | канонический адрес как есть | 100 % |
|
||||||
|
| case_lower / case_upper | регистр | 100 % |
|
||||||
|
| city_prefix | `г. Новосибирск, …` | 100 % |
|
||||||
|
| filler_dom | `… дом 5` | 100 % |
|
||||||
|
| punctuation | лишние `,`/пробелы | 100 % |
|
||||||
|
| yo | `ё` вместо `е` | 100 % |
|
||||||
|
| type_abbrev | `ул.`/`пер.`/`пр-т` | 100 % |
|
||||||
|
| type_removed | улица без слова «улица» | 100 % |
|
||||||
|
| type_front | `улица Станционная 1` | 100 % |
|
||||||
|
| corpus_synonym | `корпус` вместо `к` | 100 % |
|
||||||
|
| structure_drop | без `стр.` | 100 % |
|
||||||
|
| **typo** | опечатка в названии улицы | ~70–75 % (остальное — `ambiguous`) |
|
||||||
|
| drop_ordinal / drop_letter / drop_fraction | потеря `2-я` / литеры / дроби | потери (lossy) |
|
||||||
|
|
||||||
|
Ключевой результат: **0 случаев, когда сервис подставил неверный дом**
|
||||||
|
(«safety violations») на десятках тысяч сгенерированных кейсов. Опечатки либо
|
||||||
|
распознаются, либо честно уходят в `ambiguous`/`not_found`, но не превращаются
|
||||||
|
в чужой адрес.
|
||||||
|
|
||||||
|
## Локальный dev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build
|
||||||
|
./bin/addrkit seed -in registry.ndjson -out dev/registry.db -version 2026-10-01
|
||||||
|
cp config.example.json dev/config.json # укажите data_path и portal.url
|
||||||
|
./bin/addrkitd -config dev/config.json
|
||||||
|
curl -s localhost:6171/v1/resolve -d '{"address":"2-я Станционная улица 1/1 стр."}'
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.totmin.ru/en2zmax/addrkit/internal/adapters/registrystore"
|
"git.totmin.ru/en2zmax/addrkit/internal/adapters/registrystore"
|
||||||
"git.totmin.ru/en2zmax/addrkit/internal/domain/match"
|
"git.totmin.ru/en2zmax/addrkit/internal/domain/match"
|
||||||
|
"git.totmin.ru/en2zmax/addrkit/internal/eval"
|
||||||
"git.totmin.ru/en2zmax/addrkit/internal/seed"
|
"git.totmin.ru/en2zmax/addrkit/internal/seed"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,6 +29,8 @@ func main() {
|
|||||||
os.Exit(runVerify(os.Args[2:]))
|
os.Exit(runVerify(os.Args[2:]))
|
||||||
case "resolve":
|
case "resolve":
|
||||||
os.Exit(runResolve(os.Args[2:]))
|
os.Exit(runResolve(os.Args[2:]))
|
||||||
|
case "eval":
|
||||||
|
os.Exit(runEval(os.Args[2:]))
|
||||||
case "help", "-h", "--help":
|
case "help", "-h", "--help":
|
||||||
usage()
|
usage()
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
@@ -44,6 +48,7 @@ Usage:
|
|||||||
addrkit seed -in registry.ndjson -out registry.db [-version 2026-10-09]
|
addrkit seed -in registry.ndjson -out registry.db [-version 2026-10-09]
|
||||||
addrkit verify -db registry.db
|
addrkit verify -db registry.db
|
||||||
addrkit resolve -db registry.db "2-я Станционная улица 1/1 стр."
|
addrkit resolve -db registry.db "2-я Станционная улица 1/1 стр."
|
||||||
|
addrkit eval -db registry.db [-sample 500] [-seed 1]
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,3 +149,31 @@ func runResolve(args []string) int {
|
|||||||
_ = enc.Encode(res)
|
_ = enc.Encode(res)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runEval generates citizen-style perturbations from the seeded registry and
|
||||||
|
// reports how well the resolver copes. Results are deterministic per -seed.
|
||||||
|
func runEval(args []string) int {
|
||||||
|
fs := flag.NewFlagSet("eval", flag.ExitOnError)
|
||||||
|
db := fs.String("db", "", "SQLite database")
|
||||||
|
sample := fs.Int("sample", 500, "number of registry entries to sample")
|
||||||
|
seedVal := fs.Int64("seed", 1, "random seed")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
if *db == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "eval: -db is required")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
_, entries, err := registrystore.Load(*db)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "eval: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
ix := match.NewIndex("eval", entries, match.DefaultOptions())
|
||||||
|
rng := rand.New(rand.NewSource(*seedVal))
|
||||||
|
cases := eval.Generate(entries, *sample, rng)
|
||||||
|
report := eval.Run(ix, cases)
|
||||||
|
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
_ = enc.Encode(report)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
"log_level": "info",
|
"log_level": "info",
|
||||||
"resolve": {
|
"resolve": {
|
||||||
"fuzzy_threshold": 0.82,
|
"fuzzy_threshold": 0.82,
|
||||||
"ambiguity_margin": 0.03,
|
"ambiguity_margin": 0.06,
|
||||||
"max_candidates": 5
|
"max_candidates": 5
|
||||||
},
|
},
|
||||||
"portal": {
|
"portal": {
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ var (
|
|||||||
|
|
||||||
// "2-я", "10-й", "3-е"
|
// "2-я", "10-й", "3-е"
|
||||||
reOrdinalToken = regexp.MustCompile(`^(\d+)-[а-я]{1,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
|
// 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.
|
// part is best-effort and whose HasHouse is false.
|
||||||
func Parse(raw string) ParsedAddress {
|
func Parse(raw string) ParsedAddress {
|
||||||
normalized := NormalizeText(raw)
|
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}
|
p := ParsedAddress{Raw: raw, Normalized: normalized}
|
||||||
|
|
||||||
rest := normalized
|
rest := normalized
|
||||||
@@ -69,28 +75,42 @@ func firstNonEmpty(values ...string) string {
|
|||||||
|
|
||||||
// parseStreet fills the street-related fields of p from the leftover string
|
// parseStreet fills the street-related fields of p from the leftover string
|
||||||
// (the address with its house block removed).
|
// (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) {
|
func parseStreet(p *ParsedAddress, s string) {
|
||||||
tokens := strings.Fields(s)
|
tokens := strings.Fields(s)
|
||||||
if len(tokens) == 0 {
|
|
||||||
p.StreetKey = streetKey("", "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
name := make([]string, 0, len(tokens))
|
name := make([]string, 0, len(tokens))
|
||||||
typeFound := false
|
streetType := ""
|
||||||
|
|
||||||
for _, tok := range tokens {
|
for _, tok := range tokens {
|
||||||
// Only the first recognised type token is treated as the type; a
|
// Filler tokens ("г", "город", "дом", "область", ...) never belong to
|
||||||
// second occurrence is part of the name.
|
// the street name.
|
||||||
if !typeFound {
|
if fillerWords[tok] {
|
||||||
if t, ok := streetTypes[tok]; ok {
|
continue
|
||||||
p.StreetType = t
|
}
|
||||||
typeFound = true
|
if t, ok := streetTypes[tok]; ok {
|
||||||
continue
|
if streetType == "" {
|
||||||
|
streetType = t
|
||||||
}
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
name = append(name, tok)
|
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)
|
name = extractOrdinal(p, name)
|
||||||
p.NameDisplay = strings.Join(name, " ")
|
p.NameDisplay = strings.Join(name, " ")
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,24 @@ func TestParseHouseBlock(t *testing.T) {
|
|||||||
wantOrdinal: "1", wantName: "ельцовка", wantType: "улица",
|
wantOrdinal: "1", wantName: "ельцовка", wantType: "улица",
|
||||||
wantHouse: "1", wantBLetter: "д", wantHas: true,
|
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 {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -2,6 +2,18 @@ package address
|
|||||||
|
|
||||||
import "strings"
|
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
|
// streetTypes maps a surface form (including common abbreviations) to a single
|
||||||
// canonical street type. The type is informational for matching (we strip it
|
// canonical street type. The type is informational for matching (we strip it
|
||||||
// from the key) but useful for disambiguation and display.
|
// 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
|
// ordinalWords maps spelled-out ordinals to their digit form. Only the forms
|
||||||
// that actually occur in practice are listed; unknown words are treated as
|
// that actually occur in practice are listed; unknown words are treated as
|
||||||
// part of the street name.
|
// part of the street name.
|
||||||
var ordinalWords = map[string]string{
|
var ordinalWords = map[string]string{"первый": "1", "первая": "1", "первое": "1",
|
||||||
"первый": "1", "первая": "1", "первое": "1",
|
|
||||||
"второй": "2", "вторая": "2", "второе": "2",
|
"второй": "2", "вторая": "2", "второе": "2",
|
||||||
"третий": "3", "третья": "3", "третье": "3",
|
"третий": "3", "третья": "3", "третье": "3",
|
||||||
"четвертый": "4", "четвертая": "4", "четвертое": "4",
|
"четвертый": "4", "четвертая": "4", "четвертое": "4",
|
||||||
|
|||||||
@@ -45,10 +45,15 @@ type Options struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DefaultOptions returns conservative defaults.
|
// 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 {
|
func DefaultOptions() Options {
|
||||||
return Options{
|
return Options{
|
||||||
FuzzyThreshold: 0.82,
|
FuzzyThreshold: 0.82,
|
||||||
AmbiguityMargin: 0.03,
|
AmbiguityMargin: 0.06,
|
||||||
MaxCandidates: 5,
|
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 {
|
if p.Ordinal != "" && s.ordinal != "" && p.Ordinal != s.ordinal {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if sim := ratio(p.NameKey, s.nameKey); sim > best {
|
if sim := nameSimilarity(p.NameKey, s.nameKey); sim > best {
|
||||||
best = sim
|
best = sim
|
||||||
bestKey = s.key
|
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 {
|
if p.Ordinal != "" && s.ordinal != "" && p.Ordinal != s.ordinal {
|
||||||
continue
|
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)
|
ambiguousKeys = append(ambiguousKeys, s.key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package match
|
package match
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
// levenshtein returns the edit distance between two strings, measured in
|
// levenshtein returns the edit distance between two strings, measured in
|
||||||
// runes. It uses the rolling-row formulation: O(min(n,m)) memory.
|
// 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
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -101,6 +101,14 @@ func Build(sources []Source) ([]address.Entry, Stats) {
|
|||||||
appendSample(&stats, src.Address)
|
appendSample(&stats, src.Address)
|
||||||
continue
|
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{
|
entry := address.Entry{
|
||||||
ID: src.ID,
|
ID: src.ID,
|
||||||
Address: src.Address,
|
Address: src.Address,
|
||||||
@@ -133,12 +141,6 @@ func Build(sources []Source) ([]address.Entry, Stats) {
|
|||||||
|
|
||||||
entries = append(entries, entry)
|
entries = append(entries, entry)
|
||||||
streets[p.StreetKey] = struct{}{}
|
streets[p.StreetKey] = struct{}{}
|
||||||
if p.HasHouse {
|
|
||||||
stats.Parsed++
|
|
||||||
} else {
|
|
||||||
stats.Unparsed++
|
|
||||||
appendSample(&stats, src.Address)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
stats.DistinctStreets = len(streets)
|
stats.DistinctStreets = len(streets)
|
||||||
sort.Strings(stats.UnparsedSample)
|
sort.Strings(stats.UnparsedSample)
|
||||||
|
|||||||
Reference in New Issue
Block a user