Skip to content

About

Rust address resolution with libpostal, Nominatim, ClickHouse, Connect and gRPC

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

OpenGeocoding

CodSpeed

A Rust service that combines libpostal address parsing, Overture-backed index lookups and the administrative regions of that index.

  • Connect JSON and Connect protobuf; an experimental legacy gRPC surface.
  • Forward geocoding, reverse geocoding to the nearest houses or streets, reverse region resolution from administrative polygons and a list of the regions the index knows.
  • Country/region corroboration, local-language candidate matching (including each place's names in other languages and scripts), postcode-area safeguards and ordered fallback queries.
  • goodMatch is false for structured-fallback results, for street results that lack the requested house number, for a P.O. box (the town is answered), and for a place that explains less than half of a long query (a "weak" guess: "Twin City Shopping Centre, Burgersfort" is not Thuin). When the whole query is such a guess or the parser garbled it, each comma-separated part is searched on its own and a settlement or postcode that accounts for at least three of the query's words (or more than the guess did) answers, with goodMatch false unless it accounts for every word.
  • Postcodes are returned as the country writes them (1012 LG, H9R 5Z5, HA5 5NE, 140 00) where the shape is known, and a territory of the US, France, Norway, the Netherlands or China answers under its sovereign's country code, as the Nominatim deployment did (Greenland, the Faroes and the British territories keep their own).
  • Prometheus metrics, gRPC reflection and graceful shutdown.
  • A non-root, Python-free runtime for Linux amd64 and arm64.

This is an initial implementation, not a production accuracy or throughput guarantee. libpostal remains a native C parser, not a pure-Rust replacement. Calls are serialized off the async executor pending native concurrency validation.

Run with Docker

Build the image locally:

docker build --target runtime -t opengeocoding .

Or pull a published image:

docker pull ghcr.io/mokto/opengeocoding:latest
docker tag ghcr.io/mokto/opengeocoding:latest opengeocoding

latest is the newest release. Models and indexes are not included in the image.

Download libpostal models explicitly into a writable directory. Downloads are large; nothing downloads automatically at import or startup:

mkdir -p models
docker run --rm --entrypoint libpostal_data \
  -v "$PWD/models:/data" opengeocoding download all /data

The resulting directory is models/libpostal. Keep it for subsequent runs.

Build an index into out/ (see the index section below), and mount it read-only:

docker run --rm \
  -p 11121:11121 -p 8000:8000 \
  -v "$PWD/models/libpostal:/data/libpostal:ro" \
  -v "$PWD/out:/data/index:ro" \
  -e INDEX_DIR=/data/index \
  opengeocoding

Addresses inside Docker must be reachable from the container; localhost refers to that container. The service needs nothing else at runtime: it derives its administrative regions from the index at startup.

Deploy behind a trusted network or authenticated proxy. The RPCs, including region-map refresh, do not implement authentication. Do not expose them directly to the public internet.

Example request:

curl http://localhost:11121/opengeocoding.OpenGeocoding/SearchAddress \
  -H 'Content-Type: application/json' \
  -H 'Connect-Protocol-Version: 1' \
  -d '{"addressString":"Copenhagen, Denmark"}'

The contract is proto/opengeocoding-v1.proto, package opengeocoding, service OpenGeocoding. The methods are SearchAddress, ResolveRegion, ListRegions, SuggestAddress and ReverseGeocode. RefreshRegionMap and GetRegionMap (a Python-style dictionary string) were removed; ListRegions returns the same regions as typed messages.

The canonical RPC path is /opengeocoding.OpenGeocoding/*. The Go package is github.com/Mokto/opengeocoding/gen/opengeocoding; the Rust crate is opengeocoding, with generated types under opengeocoding::proto::opengeocoding.

/geolocation.Geolocation/* and /nominatim_geolocation.NominatimGeolocation/* remain aliases for every method, including for benchmarking. Message fields, numbers and types are unchanged. These aliases do not enable a Nominatim backend. gRPC reflection advertises only opengeocoding.OpenGeocoding, and the grpc_service metric label uses this canonical name on all routes.

Address suggestions

SuggestAddress completes text as it is typed and returns up to limit (default 5, at most 15) suggestions, each with an opaque id (stable for one index), a display_name, a point (places also a bounding box when they have an extent), a type, a country_code and, for a landmark (see Landmarks below), a category. country_code restricts the answer to one country. There is no second lookup call: the point is in the suggestion.

focus (lat, lon and scale_km, default 100, at most 20,000) is the user's position: each place's weight is multiplied by 1 + 1000 / (1 + (d / scale_km)²), where d is its distance in km, so by 1,001 at the point, 501 at scale_km and about 11 at ten times that. With a focus near Dallas paris is Paris, Texas, and main st, spri near Hartford lists Springfield, Massachusetts first. It re-ranks the at most 400 best-known places a name or prefix finds and the towns that scope a street; streets, houses, postcodes and landmarks themselves are not ranked by distance.

curl http://localhost:11121/opengeocoding.OpenGeocoding/SuggestAddress \
  -H 'Content-Type: application/json' -H 'Connect-Protocol-Version: 1' \
  -d '{"text":"10 downing street, lon","limit":3}'

Postcodes can be qualified by a town or country (75001, Paris, SW1A 2AA, UK); contradictory qualifiers are rejected. Native CJK and Turkish names use the index's Unicode normalization even while the last word is unfinished, and a digit in a place name does not by itself turn the query into a house-number lookup.

It does not call libpostal, which misreads half-typed words. The commas say where the road ends and the town starts (12 rue de rivoli, par), and without commas the end of the text may still name the town (main st spri). The last word may be unfinished unless the text ends in a space or comma. Places come from an in-memory FST of the folded names, in every language, of the places whose weight (population, or a guess by kind) is at least 5,000, built at startup (25 MB and about 0.6 s more on the world index, plus about 100 MB briefly while it is built); ranking is that weight times how much of the name was typed, so londo is London, not the hamlet named Londo, and ma lists countries. Every later word of a main (local or English) name of a place weighing 100,000 or more starts a key of its own, from three characters typed: york lists New York and lumpur Kuala Lumpur, weighed like a name that only contains the text but listed before streets (11,000 keys; the FST grows from 25.9 to 26.2 MB). When the text as typed leaves room among the suggestions, the places whose name is one edit (an insertion, deletion, substitution or transposition of adjacent letters) from a text of at least five letters, or starts so, with the first letter right, fill it after all others: a Levenshtein automaton over the same FST finds lodnon, amsterdma and new yrok. After a comma a misspelt town scopes streets when no town is named as typed (oxford street, lodnon). Smaller places, streets, house numbers (typed last, the numbers that start with them, up to four per street) and postcodes (three characters or more) come from the index's terms. A street search over words that many streets share is not run: without a town it would walk millions of postings to give an arbitrary street, so main st waits for a town, which can be a country or region code (main st, il). Place order and the weights are hand-set heuristics.

Not supported: misspelt streets; a misspelt place when the text as typed fills the suggestions (munchne starts five streets named Münchner Straße, so München does not come, a known failure in the golden cases); a typo in the first letter or two typos; a later word of a smaller place's name (only the index's whole words find those, after streets); ranking streets, houses and postcodes by distance from focus. One or two letters of a lone street word are not completed.

The gold suite checks suggestions too, with places ranked by population (see Geographic evaluation). tests/suggest.rs checks 232 golden cases (each place's country and coordinates are public facts, not this service's answers): cities by native and foreign spelling, other scripts, namesakes ranked by likelihood, the country filter, regions, countries, neighbourhoods, streets, houses, postcodes, one-letter typos, namesakes that a focus (the focus column) decides and later words of a name. For 40 of them every prefix from a given length on must also find the place, and no answer may break the limit, the country filter or repeat an id. 4 cases are known failures, three of them source-data gaps (a county without a population, a borough that is not an ancestor, a postcode placed in the wrong country) and one the misspelt munchne above, listed with their cause in the known_failure column of tests/suggest.gold.tsv; each must keep failing until its label is removed. The test also times every prefix of 29 typed strings against a world index (INDEX_DIR=out cargo test --release --test suggest -- --ignored --nocapture). On an 18-core laptop with a 10 GB world index, 380 typed prefixes took, after the service's warm-up (about 0.8 s on a thread of its own: the one- and two-letter prefixes and the names of countries and regions), p50 0.2 ms, p90 1.7 ms and p99 5 ms in the index on their first typing (max 10-17 ms, the first time a town's ancestors are read), and p50 0.12-0.14 ms, p90 1.3-1.4 ms and p99 4.4-4.6 ms (max 7-8 ms) when typed again. The two prefix caches hold at most 20,000 entries each, under about 150 MB. A request over Connect took 24 ms by curl, including the connection. These are one machine's numbers on public strings, not capacity or request-latency guarantees. Typo tolerance, focus and later words of names measured no slowdown beyond that laptop's noise when it was busy with other work: alternating the run before and after them twice, repeat p50 was 158-188 µs before and 82-140 µs after, p99 5.1-6.8 ms before and 2.6-4.3 ms after.

Landmarks

With include_landmarks (includeLandmarks in JSON, default false) suggestions may also be landmarks: type landmark, with a category of airport, station, attraction, museum, university, hospital or stadium (empty for every other type). Without it the answer is what it was before landmarks existed: they live in their own file and name FST, which nothing else reads.

curl http://localhost:11121/opengeocoding.OpenGeocoding/SuggestAddress \
  -H 'Content-Type: application/json' -H 'Connect-Protocol-Version: 1' \
  -d '{"text":"gare du nord","includeLandmarks":true}'

They come from the OSM extracts the builder reads (--osm, and --osm-landmarks, below), never from a business or POI source: features with a wikidata tag that are aeroway=aerodrome with an iata tag (the code is one more name, so CDG finds Paris Charles de Gaulle), railway=station or public_transport=station with train=yes, tourism=museum, tourism=attraction|theme_park|zoo (category attraction), amenity=university, amenity=hospital or leisure=stadium, tried in that order. A Wikidata item is one landmark: of its copies, the one with the most names. Its point is a node's, or a planar area centroid of closed outer rings (a vertex mean for open ways or fragmented relations). Holes and nested relation members are not assembled, and geometries crossing the antimeridian are omitted. The point gets the same polygon assignment as a street and the same address line, which can repeat ancestor names (Paris Gare du Nord, Paris, Paris, Paris, Ile-de-France, France). A landmark that assignment places nowhere is left out (439, among them Dubai International Airport, outside locality polygons and more than 5 km from the nearest locality point). The builder writes landmarks.tsv and landmarks.fst; the service maps both read-only. It does not build landmark names at startup.

A name matches from its start, and the local, English and alternative names also from a later word of three letters or more (gare du nord finds Paris Gare du Nord, heathrow finds London Heathrow Airport). A name before a comma is whole words, and what follows the comma narrows like a place's qualifier (gare du nord, paris); country_code applies. Two typed characters find no landmark. Landmarks come after the existing places named by the text, which keep their positions (Paris is Paris, France first, and with the default limit of 5 no landmark follows it), and before the streets and places that only contain the text. Among themselves, airports come first, then stations, then the rest, and within a tier the landmarks OSM names in more languages: a stand-in for fame that favours what is known abroad over what is known locally.

On 2026-10-16, with the October sources and 78.1 GiB of OSM extracts (including osmLandmarks), a world build on an 18-core Apple M5 Max with 64 GB RAM wrote 169,068 landmarks:

Category Count
airport 7,842
station 69,888
attraction 35,372
museum 29,871
university 9,095
hospital 10,498
stadium 6,502

The landmark files are 19.6 MB of rows and 21.3 MB of names beside a roughly 16 GB index, including the administrative and street reverse files and GeoNames places. The 2026-10-17 build took 409 s with a 32.2 GB peak memory footprint; it was not an isolated before/after measurement. Moving name compilation to the builder and mapping names instead of owning them reduced startup anonymous-memory overhead against a non-landmark base from 68.5 MiB to 4.4 MiB in one Linux arm64 check on the same index; file-backed pages are not included. The prefix cache holds at most 20,000 entries of 400 ids (32 MB of ids, plus keys and map overhead). These are local measurements, not resource guarantees.

tests/gold.landmarks.jsonl types 71 names and IATA codes of 59 Wikidata items in 32 countries with the flag on (full), then repeats each query with the flag explicitly false (off). The flag-on checks require the category, country and a point within 3 km of Wikidata's point for airports, 1 km for most others. Flag-off checks require no landmark result and fail the gate on a leak or RPC error. Regenerate it, never from a geocoder's answers:

target/release/evaluate build-gold --seeds tests/gold.landmarks.seeds.jsonl --out tests/gold.landmarks.jsonl

On the world index above, 53 of 71 flag-on checks and all 71 flag-off checks passed. The report lists the 18 misses: nine names or IATA codes crowded out by existing places; Louvre's museum behind stations and its pyramid; four names OSM does not carry in the queried form; Grand Central Terminal and Wembley Stadium classified as attractions by their selected OSM features; Dubai's unassigned airport; and Charité's institution item absent while its campuses are present. No case was dropped or labelled to turn a failure green. These are geographic/category checks, not item-identity verification or production accuracy; a nearby landmark of the same category can pass. The cases ask SuggestAddress only and are unscored for other RPCs.

The 71 flag-on requests took 26.5 ms in total (p50 0.22 ms, p90 0.53 ms, p99 and max 4.61 ms), each timed as the fastest of three local RPC calls. The existing 380-prefix typing workload also ran with the flag on (cargo test --release --test suggest -- --ignored --nocapture):

Warm typing, 380 prefixes p50 p90 p99
flag off 0.076 ms 0.765 ms 2.504 ms
flag on 0.045 ms 0.682 ms 2.524 ms

The flag-on pass followed the flag-off pass and reused its place caches. These laptop timings do not establish a speedup or a request-latency guarantee.

Limits: OSM coverage is uneven, and "notable" means "OSM links it to Wikidata": a landmark mapped without the tag is absent, and well-mapped countries carry far more of them. Categories are only the seven above, from OSM's tags as they are: there are no businesses, shops or other points of interest, and no opening hours or other details. There is no typo tolerance or arbitrary substring matching, a station named after a landmark ranks before it (Tour Eiffel gives the RER station Champ de Mars - Tour Eiffel, then the tower), and a landmark whose name or code starts like five larger places is not in the default five. SearchAddress and ReverseGeocode do not return landmarks.

Reverse geocoding

ReverseGeocode takes lat, lon, an optional radius_m (default 500, at most 2,000) and an optional size (default 1, at most 10). It returns the houses within the radius, nearest first, each a FullAddress with its distance_m and layer address. With no house in range it returns the streets within the radius that do not conflict with the containing country and region polygons, nearest first, as layer street: each at its point nearest the given one, with its place and, when the street has houses, the postcode of the house nearest that point. With neither it returns the nearest locality point (within about 50 km) as layer locality, one in the region whose polygon holds the point first (see below), and with nothing at all an empty list. A house in range always comes before a nearer street. Clients that switch on layer must accept street.

curl http://localhost:11121/opengeocoding.OpenGeocoding/ReverseGeocode \
  -H 'Content-Type: application/json' -H 'Connect-Protocol-Version: 1' \
  -d '{"lat":51.50354,"lon":-0.12768,"size":3}'

The builder writes reverse.bin next to the number FSTs: one 10-byte record for each street and each grid cell (2^6 steps of the 24-bit packed coordinates: 76 m by 153 m at the equator) that holds one of its houses, sorted by cell, with the Morton code of the first such house and the address of the street's document. A lookup binary-searches the cells around the point, ring by ring, then reads the candidate streets' houses from the number FSTs and ranks them by distance. Nothing is built at startup, and the file is memory-mapped like the FSTs. On the 2026-09-23 Overture world index with OpenAddresses and OSM it holds 132.6 million records (1.33 GB, about 13% more than the 10 GB index; 497.5 million house rows) and adds about 4 seconds to the build. On an 18-core laptop with a freshly written index in the page cache, 729 random points in western and central Europe that had a house within 500 m took p50 67 µs, p90 108 µs and p99 169 µs in the index, one point at a time. These are one machine's warm-cache numbers; a cold file costs a few page reads per lookup.

It also writes streets.bin, the lines of the named road segments that have a street document (all classes but footway, steps, path, cycleway, bridleway and track). Each line is simplified to within about 5 m (Douglas-Peucker) and cut into edges whose far end is at most 127 steps from the near end on either axis. Each edge is one 12-byte record: the Morton code of its near end, the far end's offset and the street's document, sorted by street cell (2^8 steps: 305 m by 611 m at the equator). An edge reaches at most half a cell past its near end's cell, so after r rings of cells every edge within r - 0.5 cells of the point has been read. A lookup reads rings until the nearest streets are closer than that or the radius is covered, and measures the distance to each edge. On the same world index (with GeoNames places) it holds 230.3 million edges, 2.76 GB, about twice reverse.bin; the index grew from 11 to 14 GB. The build reads and simplifies the lines in the segment pass and writes the file after reverse.bin. On a laptop shared with other index builds (load average 50 to 140), those phases took 114 s and 21 s, against 26 s and 8 s in the last build without streets, and the whole build 1,444 s against 503 s. These timings are inflated by the load and are single measurements. Of 20,000 random points in western and central Europe, 1,645 had no house within 500 m but a street. Before the nearby-locality naming refinement described below, with a warm page cache their lookups took p50 67 µs, p90 94 µs and p99 192 µs in the index. A point with neither (a locality answer) took p50 60 µs where it took 40 µs without streets.bin. Of the 3,957 gold ReverseGeocode points, 3,517 have a house within 500 m, 285 now get a street and 154 still get a locality.

Limits: any house in range comes before a street, however much nearer the street is. Unnamed segments, paths and segments without a place have no line, and a street's distance is to its simplified line, stored to about 2 m. A street starts with the place its segment centre falls in. The nearest non-hamlet locality within 1 km of the query refines it when it descends from that place. Otherwise a city, town or village at least as large in kind can supply its city name when its parent is in the street's ancestry (but not just the country); the old place becomes a suburb, and the street's region stays unchanged. This is a naming heuristic, not containment: a nearby locality can still be across a boundary, and a long street across a border still carries the segment centre's region and is excluded on the other side when a containing polygon contradicts it. A street split into two documents (at the 0.1° cells that keep same-named streets of one town apart) can be listed twice when size is more than 1. Its postcode comes from its house nearest the point, wherever that house is. ResolveRegion does not read streets. The locality fallback takes the nearest of the 3.5 million locality points within 50 km, by distance, from a grid sorted at startup (src/index/locality.rs, 28 MB, well under a second to build), where it used to run a range query over the index's coordinate columns that took about 20 to 50 ms. Unlike that query, it does not prefer the most important places in a crowded box: it names the nearest, often a hamlet, and on a sample of 4,000 points it chose another place than before for 6% (0.7% a different region code, all near borders, where a nearest point is no better a guide than the old one). Cells across the antimeridian or poles are not searched. House numbers come back in their folded, lowercase form. Searching rings use the smaller cell side, so high latitudes read more cells than needed. Accuracy is not measured against an authoritative corpus; tests/ only checks the lookup on a tiny index and, with an index, that the RPC answers.

Regions of a point

ResolveRegion answers from administrative polygons. The builder writes areas.bin next to reverse.bin: the territorial division_area polygon (land and territorial sea, so a point just offshore is placed) of every county, region, dependency and country, simplified by Douglas-Peucker to 0.0005° (55 m of latitude, less of longitude) and stored as 1e-7° integers, with each polygon's edges bucketed into horizontal slabs so a test reads only the edges near the point's latitude. The service memory-maps it and keeps only a table and an R-tree of the polygons' bounding boxes in memory. A point's answer is the most specific polygon that holds it and has a region code: a county first (so a French département adds its ISO3166-2-lvl6 code to its region's), then a region, a dependency, a country (Sint Maarten's NL-SX). Among polygons of one kind the smallest bounding box wins, then the lowest place id, so overlapping and disputed areas resolve the same way whatever the build order. The codes are those a search for the polygon's place returns, through its parents, not the polygon's own neighbours. Within the tolerance of a border a point can fall on the wrong side, or in a sliver between two simplified neighbours, which the fallback then answers.

A point no coded polygon holds (offshore past the territorial sea, a gap in the data, a country whose regions the index lacks) falls back to the nearest points: the nearest house within 100 m when its place has a region code (its place was assigned by polygon), then the nearest locality within 50 km when it has a region code. When it has none, or is past 50 km, the nearest coded locality (up to 150 km) answers only when it is in the same country and no locality of another region is within twice its distance (four times when the nearest locality had no code, which suggests the data lacks the point's own region); otherwise the answer is empty. When a country polygon holds the point, only that country's localities are read and the house is skipped, so a point in a country without coded regions does not take a neighbour's, and Hong Kong, whose CN-HK Overture files on a locality, still answers through it. ReverseGeocode's locality fallback likewise reads only the localities of the polygon's region (or of its country, with no coded polygon) when one is within 50 km.

On the 2026-09-23 Overture world index the file holds 43,122 polygons and 17.9 million vertices in 235 MB (about 2% of the index), and writing it took 2 to 7 seconds of the build on an 18-core laptop shared with other builds. On that laptop, with a load average far above its 18 cores, 10,000 points on a grid over western and central Europe took p50 1.8 µs, p90 3.7 µs and p99 6.8 µs in the polygon lookup alone, and p50 15 µs and p90 190 µs for the whole reverse_region (the slow tail is the fallback at sea, which reads houses and localities up to 150 km away, as before). These are single measurements on one busy machine, not latency guarantees. On the 2,368 gold cases that check it, ResolveRegion returned an expected region for 2,348, where the nearest-point rule returned 2,331: 17 more and none fewer (Malta's local councils, Caracas, Oshikango on the Angolan border, Brunei's Belait, two Dominican provinces and three US addresses, in five cases, that the index files under a town across a state line), and ReverseGeocode passed 2 more of 3,705 and none fewer. tests/resolve_region.rs (with INDEX_DIR, -- --ignored) checks the two sides of State Line Road and of the Neisse, Shanghai, Washington, the Quebec interior, the Baarle-Hertog enclave and five Wikidata points that the nearest-point rule placed in another region or none, against ISO 3166-2 codes, and times the lookup.

Still unanswered or wrong: regions the index does not carry (Cyprus, Kosovo, Baku, Bonaire, Sint Eustatius, Gwangju, the West Bank's governorates, South Ethiopia, and those listed under Configuration), where a point gets nothing or, when the country polygon is missing too, the nearest-point rule's answer; and places whose codes Overture or Wikidata file differently from the gold corpus (Butajira answers Oromia, Encamp and Isa Town another parish or governorate, Mata-Utu France's FR-WF).

Deploy on Kubernetes

The Helm chart in manifests/ builds the index in the cluster and serves it. It needs an S3-compatible bucket (AWS S3, MinIO, SeaweedFS, Ceph, R2 and the like) that already exists: nothing here creates it, and the leader exits when it can't list it.

kubectl create secret generic opengeocoding-s3 \
  --from-literal=AWS_ACCESS_KEY_ID=... --from-literal=AWS_SECRET_ACCESS_KEY=...
helm install geocoding oci://ghcr.io/mokto/charts/opengeocoding \
  --set store.url=s3://my-bucket/opengeocoding \
  --set store.endpoint=http://seaweedfs-s3.seaweedfs:8333 --set store.allowHttp=true \
  --set store.credentialsSecret=opengeocoding-s3

store.endpoint is only for stores other than AWS S3; any other AWS_* setting, such as a web identity role, goes in store.extraEnv. See manifests/values.yaml for every setting.

  • Leader. The chart installs no build Job and no serving Deployment of its own. A small Deployment (leader, two replicas, one holding a Lease) runs the same loop every 30 seconds with no state beyond the cluster and the bucket: if the bucket has no sealed indexes/<id>/_COMPLETE for the index the chart names, it makes sure one build Job exists, replacing a failed Job after build.retryDelaySeconds (the Job itself never retries its pod); once the index is sealed, it deletes the build Jobs and server-side applies the serving Deployment, whose rolling update is the rollout. Helm only renders both workloads into a ConfigMap (<name>-workloads). No serving pod exists, or waits in an init container, before the first index does. That ConfigMap owns the Deployment and the Jobs the leader creates (owner references), so removing the release removes them, and a GitOps tool shows them under it (Argo CD neither tracks nor prunes them). The leader moves a build Job an earlier chart's leader created (owned by the leader's Deployment, or tracked by Argo CD) under the ConfigMap the same way. build.enabled: false keeps the leader from building: it then serves an index only once something else has published it.
  • Build Job. build_index job exits at once if the bucket already holds the index the chart names. Otherwise it fetches the sources and libpostal's models, builds, opens the result as the service would and resolves one of its places, then publishes it. Each source is copied into the bucket as it arrives, so a later build or a retried pod reads the bucket instead of upstream. A world build needs about 16 CPUs, 32 GiB of memory and 250 GiB of scratch (build.*), and about 200 GB of downloads the first time, including the places theme and 49 GB of landmark-only OSM extracts (osmLandmarks). Scratch is deleted with the pod unless build.scratch.existingClaim names a volume you keep: a later build then copies from the bucket only the files that do not already match it.
  • Serving pods. An init container (build_index pull) downloads it and the models to the pod's own disk and checks every file's SHA-256. The service then opens them read-only. The service itself never downloads anything. Each pod needs about 6 GiB of memory (16 GiB keeps a world index's page cache), and 14 GB of disk for a world index.
  • Index names. The chart names the index by index.builderHash, index.modelsVersion and index.sources, and passes the same name to the Job and the pods. builderHash hashes the builder's code (build_index hash), except the code that fetches sources and talks to the store (fetch.rs, store.rs): sources are sealed in the bucket and reused as they are, so that code can change without rebuilding. A fix to how sources are selected therefore takes effect only with a new snapshot label, or for Overture (stored by release) after deleting its directory in the bucket. cargo test fails until manifests/values.yaml matches the hash, so a builder change is a visible chart change. The Job refuses an image built from other builder code, and the service refuses an index whose build.json names another builder. index.sources.snapshot labels one fetch of the sources that only publish "latest" (OSM, OpenAddresses, GeoNames); a new label fetches them again.
  • Upgrades. A change that names a new index makes the leader start a new Job (a build still running for another index is deleted: one build at a time). The serving Deployment keeps its old template until the new index is sealed, so the old index keeps serving during the build; its rollout then replaces pods one at a time (maxSurge: 1, maxUnavailable: 0). Upgrading a release installed with an earlier chart, which rendered the Deployment itself: the leader takes the Deployment over on its first round, but a GitOps tool that still tracks the old Deployment (for example Argo CD's argocd.argoproj.io/tracking-id annotation) will prune it once the chart stops rendering it, so remove that annotation first. Rolling back the values rolls back to the old index, which is still in the bucket. Nothing deletes old sources or indexes.
  • Regional builds. index.sources.bbox: [min lon, min lat, max lon, max lat] builds only that area. Overture is then read only where its row groups meet the box (copied unchanged, without decoding), OpenAddresses layers are picked by their bounds, GeoNames by row, and the builder drops everything outside the box. Pick OSM extracts in index.sources.osm by hand. A regional index answers queries about other places with whatever it holds: "Paris, France" against a Liechtenstein index answered a Paris in Lombardy, with goodMatch true.

Bucket layout, under the URL's prefix: sources/overture/<release>/<extent>/<type>/, sources/<snapshot>/{osm,osm-landmarks,openaddresses/<extent>,geonames/<extent>/<file>}/, models/libpostal/<version>/ and indexes/<id>/. Every directory is sealed by MANIFEST (paths, sizes, SHA-256) and then _COMPLETE; readers use nothing unsealed.

The same commands work without Kubernetes, against a bucket or a local directory: build_index job --store file:///srv/geocoding --index NAME --builder-hash "$(build_index hash)" --sources sources.json --scratch /tmp/scratch --models /data/libpostal, where sources.json holds index.sources as JSON, then build_index pull --store file:///srv/geocoding --index NAME --to /srv/index --models /data/libpostal. A local directory store hard-links the index and models into place instead of copying them when they share a file system, so nothing may write those files in place; pull still checks every file against MANIFEST.

The leader has unit tests for its build decisions and has not yet run against a cluster. The earlier chart, with a Job and an init container that waited, was tested on a single-node kind cluster with SeaweedFS, with a Liechtenstein box and the liechtenstein OSM extract. The first build took 3 minutes, including libpostal's 2 GB of models from GitHub, and produced a 4.7 MB index. Two pods pulled it and answered house-level queries. Changing the sources built a new index from the bucket in under 20 seconds and rolled the pods one at a time. A client polling once a second through the rollout got 150 answers out of 150, after a 10-second preStop delay was added; before it, 3 of 240 requests failed. A world build has not yet been run through the Job; the world numbers above come from building by hand.

Known interoperability issue

The initial model-backed integration run passes Connect JSON/protobuf, local-language corroboration, country/postcode rejection, reverse regions, map refresh, error handling and 64 concurrent requests. However, grpcurl 1.9.4 can list services but its SearchAddress invocation hangs, both with reflection and an explicit proto file. The integration script fails with a bounded timeout rather than skipping that failure. Legacy gRPC interoperability is not signed off; do not treat this initial push as a production-ready replacement.

Configuration

Variable Default
INDEX_DIR Required; a build_index output directory, read-only
RPC_PORT / METRICS_PORT 11121 / 8000
PARSE_CACHE 65536; address parses kept (0 parses every call). One resolve parses its query twice and libpostal's lock is shared, so repeats skip both
RUST_LOG info
SENTRY_DSN / ENV Optional

The service loads the index into its own process at startup: one process parses, looks addresses up and serves the RPCs, with no internal HTTP hop. Startup fails if INDEX_DIR holds no build_index output. The index files stay memory-mapped; the place table (about 2 GB for the world) and libpostal's models (about 2 GB) are read into memory, which took about 10 seconds from a warm disk. INDEX_URL, NOMINATIM_HOST, NOMINATIM_URL and NOMINATIM_AUTHORITY are no longer supported; there is no Nominatim fallback. Legacy RPC paths and metric prefixes remain for compatibility, not backend selection.

Overture's region column carries an ISO 3166-2 code for its regions only. Subdivisions it does not code (Ugandan, Czech and Nauruan districts, Palestinian governorates, overseas territories such as US-PR and FR-971; 180 in all) are named in data/reference.json under iso3166_2, by the Wikidata item Overture gives the division. The list of codes is a policy kept in that file, one entry per subdivision that consumers expect; update_reference refreshes only their items. The builder writes each place's Wikidata item as the last places.tsv column and the service gives the matching place that code: it becomes a region row and the address's ISO3166-2-lvl4 code, ahead of the code Overture gives the places inside it (UG-108 rather than UG-C). A code Overture supplies itself wins. Still without regions: Cyprus, Monaco, Madagascar and Singapore (Overture has no divisions with those codes' Wikidata items) and 29 Vietnamese provinces that Overture does not carry. Regions come from the index, not from a database: at startup the service builds one row per ISO 3166-2 code the index names a region or subdivision for (about 4,600 for the world), the same row a search for <cc>, <code> returns, and serves them from ListRegions (RegionsResponse, ordered by country and admin code). The rows are fixed for the life of the process and need no writable directory. A place also carries the subdivision below its region when it lies in a county that Wikidata gives an ISO 3166-2 code: ISO3166-2-lvl6 (French départements FR-68, Italian and Spanish provinces IT-MI, ES-BI, Belgian provinces BE-WLX, Czech districts CZ-806, Lithuanian municipalities, Philippine provinces, Bangladeshi and Moroccan districts, 854 codes in all, from the iso3166_2_lvl6 list in data/reference.json, which is kept and refreshed like iso3166_2). ResolveRegion and addressDetails.regions then return the region (level 4) first and the subdivision (level 6) after it, and ListRegions holds both, the subdivision's row naming its region in parent_admin_code (FR-GES). The two lists differ: iso3166_2 replaces a place's level-4 code (a Ugandan district stands for the region), iso3166_2_lvl6 adds a second one. Only counties are matched, so an address in a place whose parents skip the county, or in a county Overture does not carry, has a region only; in one comparison with Nominatim 5.x, 114 of 500 ResolveRegion points returned extra codes there, and at the six points of the issue that prompted this, this service now returns each extra code (FR-68, FR-75C, IT-MI, ES-BI, BE-WLX, CZ-806) but Alsace's FR-6AE, a collectivity that no county stands for. The index cannot give levels 3, 7 or 8, which Nominatim sometimes returns, nor level 5 outside Ireland. Overture's Irish regions are counties, so an Irish address also carries its province (ISO3166-2-lvl5, IE-L), and the service makes a place for each of the four provinces at startup from its counties (at the mean of their points, named in English and Irish): it has a region row at level 5, a search for ie, <code> finds it, but it is in no other place's chain, so free-text searches for "Leinster" still do not find it. A search for <cc>, <code> finds the subdivision's place, as it does a region's (tests/resolve_region.rs, with INDEX_DIR, checks every listed code does, bar Wallis and Futuna's three districts, and the issue's points). The subdivision codes take no part in the stated-state checks: ES-MA (Málaga) does not make "MA" agree with a Spanish candidate. A region code an address carries but the index has no region for is logged and left out of the answer. Earlier versions kept rows in ClickHouse (CLICKHOUSE_*, REGION_MAP_PATH); those settings are ignored.

/healthz reports readiness after initialization. Metrics are exposed at /metrics on port 8000.

Coordinates use protobuf float32. Postcode compatibility deliberately includes shared outward/area codes for supported national schemes; it is not a promise of an exact street-level postcode match.

Build and test

Local builds require Rust 1.99+, Buf and a compiled libpostal installation. Native library/model paths are determined by libpostal's configure options. Nonstandard linker paths can be supplied through RUSTFLAGS="-L native=/path/to/lib".

cargo build --locked --bin opengeocoding
cargo test --locked
cargo clippy --locked --all-targets -- -D warnings
cargo fmt --check
docker build --target checks .

tests/rpc.rs exercises Connect JSON/protobuf, both legacy routes, candidate safeguards, concurrent requests, metrics and shutdown against a world index. It needs an index and provisioned models, so it is ignored by default. Install grpcurl to include gRPC/reflection checks:

INDEX_DIR=out cargo test --locked --test rpc -- --ignored --nocapture

To test the final container instead of a local binary:

SERVICE_IMAGE=opengeocoding LIBPOSTAL_MODELS="$PWD/models/libpostal" INDEX_DIR=out \
  cargo test --locked --test rpc -- --ignored --nocapture

CI builds and checks both amd64 and arm64 without downloading models. CodSpeed runs the benches/ suites in its simulation mode and reports changes in instruction counts: index (the index analyzer and house-number keys; src/index/{schema,canon}.rs), matching (the service's candidate matching; src/matching.rs, src/resolver.rs, data/) and resolve (model-backed libpostal parsing of tests/addresses.tsv; src/index/mod.rs, where the parser lives). A pull request runs only the suites whose files it changes (or all of them when it changes Cargo.toml, Cargo.lock, build.rs or the workflow); pushes to main run all of them, so each main commit is a complete baseline. Only resolve loads models; none loads an index. Run them locally with cargo bench (resolve needs LIBPOSTAL_MODELS set), or cargo codspeed build && cargo codspeed run. Index searches, suggestions and reverse lookups are timed end to end by the full-index gold check, below. Regenerate THIRD_PARTY_NOTICES.txt with cargo run --locked --bin notices after dependency changes. Model-backed RPC tests run separately with provisioned models.

Local performance benchmark

The benchmark binary sends Connect JSON SearchAddress requests to running services. It first sends every query once to each service and counts differing responses, then times --requests calls per service and concurrency over keep-alive connections, rotating the service order between rounds and warming connections outside each timed batch. No failed request is retried. Its endpoint checks run in cargo test.

Start release-built services on the same index and models. Benchmarking must not depend on production backends or databases.

cargo build --locked --release --bin opengeocoding --bin benchmark
target/release/benchmark --queries queries.txt \
  --service base=http://127.0.0.1:18121 \
  --service candidate=http://127.0.0.1:18122 \
  --requests 2000 --rounds 3 --concurrency 1,8 > benchmark.jsonl

queries.txt holds one address per line, for example the query column of tests/addresses.tsv. A service URL without a route uses opengeocoding.OpenGeocoding; to benchmark old clients, use --service legacy=http://127.0.0.1:18121/geolocation.Geolocation or .../nominatim_geolocation.NominatimGeolocation. All three routes use the same resolver. Results include the client, HTTP transport, parsing, index lookups and the RPC framework on one machine; they are not production throughput or accuracy guarantees.

Overture address index

build_index builds a Tantivy index and house-number tables from a local Overture Maps release, without Nominatim. The service loads that index (src/index/) and it is its only supported lookup backend. The index remains experimental; the coverage and reverse-lookup limitations below and in Reverse geocoding still apply.

build_index job (see "Deploy on Kubernetes") fetches all of the sources below itself. To build by hand, download the division and division_area (divisions theme), segment (transportation) and address (addresses) parquet files of one release into same-named directories, about 100 GB in total, and optionally place (places theme, theme=places/type=place, 11 GB), for example:

aws s3 sync --no-sign-request \
  s3://overturemaps-us-west-2/release/2026-09-23.1/theme=addresses/type=address/ overture/address/
cargo build --locked --release --bin build_index
target/release/build_index overture out/ --openaddresses openaddresses/ --osm osm/ \
  --geonames-postal GB_full.txt --geonames-postal allCountries.txt

Overture's address theme covers about 40 countries. The optional sources fill the rest:

  • --osm DIR: every *.osm.pbf in the directory. Addressed nodes, addressed ways and multipolygon relations at their first node, addr:place addresses (numbered within the place), and addr:interpolation ways (the numbers between their addressed nodes, spaced along the way; Argentina's block ranges alone give about 45 million); the country is that of the containing place. Files must be sorted by type then id, as Geofabrik extracts are. Pick the extracts for countries without Overture addresses from download.geofabrik.de/index-v1-nogeom.json (iso3166-1:alpha2); about 150 extracts and 30 GB in October 2026. Add extracts where Overture's coverage has holes: in October 2026 it had no addresses for Bavaria or the city of Milan, so germany/bayern and italy were added (3 GB). Extracts overlap at borders and with Overture; duplicate house numbers collapse. Landmarks (see Address suggestions) are read from these extracts too.
  • --osm-landmarks DIR: every *.osm.pbf in the directory, read for landmarks only, never for addresses, whose answers would change in countries whose addresses come from Overture. The chart's osmLandmarks lists the 37 extracts of the countries osm leaves out (Western and Central Europe, North America, Japan, Australia and others; 49 GB in October 2026).
  • place/ in the Overture release directory, when present (build_index job always fetches it): Overture's places theme (points of interest from Meta, Microsoft and others under CDLA-Permissive-2.0, Foursquare under Apache-2.0, AllThePlaces under CC0-1.0). A place whose freeform address is only a house number and a street (553 Sư Vạn Hạnh, 44 Ryneveld Street; not Shop 5, Mall Road, 548/11 Rajajipuram or Calle Mayor 5) gives that house at the place's point, when the street is a road that Overture's segments name in the same place and 0.1° cell, and only in the United Kingdom and Vietnam in this first rollout, where fewer than 10,000 Overture address rows with a street fall in that country (counted by the place that holds them, so Réunion's BAN addresses, which Overture files under France, cover Réunion). The place's postcode and locality are not read: whoever listed the business typed them. Its houses rank last among duplicates, and they neither move a street's point nor add to its weight, so a road with one listed business keeps its segments' middle and its rank among same-named roads. In the October 2026 build it added 836,488 houses in those two countries and no street; see House-number coverage below.
  • --openaddresses DIR: every *.geojson.gz in the directory, one OpenAddresses batch job output each, named <cc>_<source path>-<layer>.geojson.gz (the country comes from the prefix). List address layers with batch.openaddresses.io/api/data and download v2.openaddresses.io/batch-prod/job/<job>/source.geojson.gz for the sources Overture doesn't list (about 2,700 files, 13 GB in October 2026). Many overlap Overture's data; identical house numbers on the same street collapse (the copy from the best-ranked source is kept, Overture before OpenAddresses before OSM before Overture's places, then the one with the smallest coordinates; it takes the postcode of the first duplicate that has one when it has none, so a source without postcodes never strips one), and in a sample 19% of US and 60% of Mexican OpenAddresses addresses were on a street the Overture-only index had without that number. Each file needs its source definition beside it as <cc>_<source path>-<layer>.source.json: the JSON at the job's source URL from batch.openaddresses.io/api/job/<job> (for a deleted source, its last version in the openaddresses/openaddresses git history; build_index job doesn't recover those, so it reads them only with --openaddresses-any-licence, and in October 2026 that left out about 400 files). Only files whose layer licence is recognised as public domain, open with at most attribution, or ODbL are read; a file with no definition, no stated licence, an unrecognised one, another share-alike licence or a ban on sale or commercial use is skipped. The classification is a conservative text match, not legal review. A file whose definition states no licence is read anyway when src/bin/build_index/openaddresses_reviewed.tsv lists it: those entries were checked against the publisher's own terms (statewide files for 12 US states and DC, a few US cities and counties, and sources in Canada, Austria, Germany, Hong Kong and Argentina; the file also records the sources read and refused, with the reason). The list never overrides a stated ban on sale or commercial use, or a share-alike licence, and each entry's terms and credit line go to ATTRIBUTION.txt. --openaddresses-any-licence reads every file. Each file's licence and whether it was read go to out/openaddresses.tsv.
  • --bbox MINLON,MINLAT,MAXLON,MAXLAT: index only streets, addresses and house numbers inside the box. Places from the input are kept as they are.
  • --geonames-postal FILE, repeatable: GeoNames postal-code dumps (download.geonames.org/export/zip/, CC-BY-4.0). allCountries has only outward codes for GB and 3-character codes for CA; GB_full, CA_full and NL_full hold full codes. A postcode is added only when the address data and earlier files lack it.
  • --geonames-places FILE [--geonames-admin1 FILE]: GeoNames cities500.txt and admin1CodesASCII.txt (download.geonames.org/export/dump/, CC-BY-4.0). A populated place is added (as a locality, or a neighborhood for PPLX; historical, abandoned, destroyed and religious ones are skipped) only when no place of the same country and folded name, under any of its names, lies within 10 km, so it fills a gap and leaves Overture's places as they are. A place answers to its name and ASCII name only: GeoNames' other names include labels like "Huy (Tihange)" and the names of the place's district, which then answered queries for other places. Its parent is the place that holds it (as for addresses); with none, the Overture region named by its admin1 name. Street and address assignment does not see these places. The gold corpus's cities were picked from GeoNames, so its gain from this source overstates the gain for places GeoNames lacks. build_index job always reads it; index.sources has no switch for it, nor for OpenAddresses (whose licence check always applies).

The writer leaves one Tantivy segment per arena flush (319 for a world index) and every query looks its terms up in each, so the build ends by merging them into four, in parallel; the merge took 40 seconds for a world index and shrank its Tantivy files from 5.5 GB to 4.2 GB. One segment took 140 seconds and queried no faster.

The output holds places, streets (one per normalized name per place and 0.1° cell, merging road names with address streets) and postcodes in Tantivy, plus house numbers with coordinates in sharded FSTs. The cell keeps same-named streets of one large locality apart (Port Chalmers' and central Dunedin's Beach Street); a street crossing a cell edge is indexed in parts, and a lookup answers the middle of the parts. Addresses without a street (Overture's Polish, Czech, Slovak and Baltic village addresses, OSM addr:place) are numbered on a street named after their village.

Names are folded by one analyzer at build and query time: Unicode tokens, lowercased, ASCII-folded, then street-name abbreviations canonicalized (src/index/canon.rs: Avenue/Ave/Avenida/Av. → av, Straße and compounds → str, Calle/C/ → cl, Drottninggatan → drottningg like Drottningg., and so on for English, Spanish, Portuguese, French, German, Dutch, Nordic, Hungarian, Italian, Turkish, Polish, Russian and Ukrainian; Japanese chōme and Taiwanese section numerals read as digits, so 芝公園四丁目 and 芝公園4丁目 match). A query must use the same analyzer, including when correcting typos against the canonical vocabulary.

Streets and addresses are assigned to the containing locality polygon, else a locality point within 5 km, else the local admin area or county. House numbers are spilled to out/.numbers-spill during the build (about 7 GB for the world) and removed afterwards. The builder reads available memory at startup (cgroup limit and usage, capped by the host's MemAvailable; vm_stat on macOS) and sizes the index writer and the number sort from the remaining headroom. One world release took about 100 seconds on an 18-core laptop with a 19 GB peak memory footprint; with all the OSM extracts, OpenAddresses and GeoNames files above it took about 300 seconds and 20 GB (OpenAddresses about 75 seconds, OSM about 105), for 563 million house numbers and 43 million streets in 12 GB. These are single measurements, not guarantees, and the build needs that memory regardless of the writer budget.

Known limitations: outside Overture's address countries, house numbers are only as complete as OSM and OpenAddresses there, with addresses of points of interest also read in the UK and Vietnam (sparse in the UK, India and much of Africa and Asia; see House-number coverage); a point of interest is placed where its listing puts it, which is not always its door; OSM ways and relations are placed at their first node; interpolated numbers assume even spacing; abbreviations outside that table (MLK, CR 123 for County Road) still index as separate streets; house numbers are matched lowercased and without full-width forms or a trailing 号/號, otherwise exactly as written. Overture's divisions and transportation themes and OpenStreetMap are ODbL (attribution "© OpenStreetMap contributors", share-alike for a distributed derived database); OpenAddresses licenses vary per source and many state none; review them before distributing an index. Builds are reproducible: two builds of the same sources and builder produce the same documents, terms and house numbers, so a gold comparison of two indexes shows only the change under test. build_index digest DIR [--dump FILE] prints a checksum of each part (documents, term counts, house numbers with their postcodes) that ignores segment layout, so two builds can be checked with it; the files themselves still differ byte for byte, because tantivy names its segments at random. Order-dependent choices are made from content instead: a street shows its spelling that is not all capitals and sorts first, keeps the postcodes, postal cities and alternative names that sort first when it has more than 20, 5 or 64, and takes its centroid from sums in units of 2^-24 degree; a segment joins only an address street of its place's country; street ids follow the streets' key order. Lookups break ties between equally important candidates by id, then coordinates, not by document order, which depends on how many threads wrote the index.

House-number coverage

evaluate coverage --index DIR [--population countryInfo.txt] [--out FILE] reads a built index (no service, no libpostal; 12 seconds for the world) and writes one row per country: house numbers, streets, streets with at least one number, and, with GeoNames' countryInfo.txt (CC-BY-4.0), population, houses per 1,000 people and an estimated gap, the houses missing to 0.3 per person. The rows are ordered by that gap. 0.3 is a round figure between the indexed French addresses (0.38 per person) and German addresses (0.26); countries of large apartment blocks or large households need fewer addresses per person, so their gap is overstated, and a country can exceed it (Argentina's interpolated block ranges give 1.0). It ranks countries; it is not a completeness measure. Counts are final deduplicated FST keys, not input rows or unique buildings; interpolation counts too. A “street” is an index document (name, place and 0.1° cell), not a physical road. Ratios are rounded. Countries in the population file with no streets are included with zero counts.

The largest gaps in one world build (Overture 2026-09-23.1 with the OSM, OpenAddresses and GeoNames sources above), before and after Overture's places were added. The full measurement records every country's before/after counts (base builder 4f714061845f4003, candidate 0a3e9077e88a1702; the trial column records the rejected wider import). Population: GeoNames countryInfo.txt, retrieved 2026-10-16, SHA-256 c55f638cfaafb4fe45a9aa15120a0f1f78e8a34a8e1847d7b9320cf740fbc9ab.

Country Population Houses Per 1,000 people Streets with a number Estimated gap
India 1,464 M 85,596 0 13% 439.1 M
China 1,407 M 48,642 0 3% 421.9 M
Indonesia 286 M 68,272 0 5% 85.6 M
Pakistan 255 M 96,391 0 21% 76.5 M
Nigeria 238 M 7,276 0 8% 71.3 M
Bangladesh 176 M 18,323 0 17% 52.7 M
Ethiopia 135 M 425 0 6% 40.6 M
Philippines 117 M 64,285 1 9% 35.0 M
DR Congo 113 M 8,584 0 13% 33.8 M
Egypt 118 M 2,323,776 20 70% 33.2 M
Russia 144 M 11,603,910 81 70% 31.5 M
Vietnam 102 M 73,267 → 293,395 3 24% 30.2 M
Iran 92 M 12,231 0 2% 27.7 M
Turkey 86 M 195,318 2 10% 25.6 M
Mexico 132 M 16,182,651 123 60% 23.4 M
Thailand 72 M 43,169 1 12% 21.4 M
Tanzania 71 M 16,032 0 14% 21.1 M
South Africa 65 M 133,160 2 8% 19.3 M
Kenya 58 M 1,132 0 4% 17.3 M
Myanmar 55 M 4,995 0 12% 16.5 M

For comparison, the United States has 329 houses per 1,000 people (112.4 million), France 385, Germany 256, Japan 307 and the United Kingdom 85 (5.29 million before, 5.90 million after). The first rollout adds 616,360 houses in the United Kingdom and 220,128 in Vietnam (four times as many there), for 501.7 million houses and the same 39.2 million streets. These are single-build counts, not recall estimates.

A wider trial added 1.19 million houses in 166 countries, with further large additions in South Africa (+163,000), Malaysia (+57,000), the Philippines (+40,000), Ireland (+13,000), India (+12,000) and Nigeria (+11,500). It failed the gold gate on two reverse lookups: Valencia, Venezuela stopped naming Valencia and named Miguel Peña instead; a Lagos address got a nearer house across the Ogun State border. Those regressions were not relabelled or accepted. The broader rollout is deferred; its potential counts remain in the measurement's trial columns. Thailand's potential gain is only 5,000. Some listings and segments use different scripts (for example Rama IV Rd versus Thai), and an address without a matching road is dropped.

Sources considered for the largest gaps, October 2026 (licence findings are a reading of the publishers' terms, not legal review):

  • Adopted: Overture's places theme, CDLA-Permissive-2.0, in the same bucket and release as the rest of Overture, so the build Job fetches it with no new credentials. This review's usable bulk candidate reaching the largest gaps; the first rollout takes only its two largest measured country additions, the UK and Vietnam. Every further country needs its own full-index gold comparison. The September 2026 files' sources.license also names Apache-2.0 for Foursquare and CC0-1.0 for AllThePlaces. CDLA allows commercial use, modification and redistribution with its agreement text; Apache requires its licence, retained notices and a change notice. The builder includes both licence texts, Foursquare's copyright and the changes made in PLACES-LICENSES.txt, for hand-built and Job-built indexes. These permissive licences add no share-alike restriction conflicting with ODbL: the combined index still carries its OSM-derived database obligations; permissive inputs do not remove those. Listings are typed by businesses, so only the plainest shape on a known road is read.
  • India, China, Indonesia, Pakistan, Nigeria, Bangladesh, Ethiopia, DR Congo, Iran, Turkey, Thailand, Myanmar, Russia, Egypt: this review did not establish a downloadable national address register with coordinates and redistribution permission. That is not proof that none exists; OpenStreetMap, already read, remains the main source. Russia's FIAS/GAR is an address register, not a replacement for geocoded house points. The OpenAddresses countrywide files for South Africa, Saudi Arabia and Egypt are uploads with no stated provenance or terms and stay refused (openaddresses_reviewed.tsv).
  • United Kingdom: AddressBase is licensed by Ordnance Survey and not open; OS Open UPRN (OGL) has 40 million points but no address text, and Code-Point Open only postcodes. Nothing to add beyond OSM and places.
  • South Korea: the road-name address database with building entrances (juso.go.kr, about 6.4 million buildings) is listed on data.go.kr, but each download needs an application with identity verification, which a build Job cannot do; the 2017 OpenAddresses copy came from someone else's application and stays refused.
  • Sweden: Lantmäteriet offers CC0 open geodata, but its address download is subject to legal review and special terms. Do not infer that the address product is unrestricted from the general CC0 page. A follow-up candidate after confirming redistribution rights and obtaining access (Sweden has 99 houses per 1,000 people).
  • Bavaria: its open data (CC BY 4.0) has building outlines, while the house-coordinate service requires an agreement and credentials; the OSM extract stays.
  • Ireland: GeoDirectory and Eircode are licensed, not open.

The UK/Vietnam rollout passes the relative gold gate on the same 4,188-case suite, consumer binary and models: no newly failing or worse cases. SearchAddress goes from 3,655 to 3,656 passes (Belvedere Road in London); SuggestAddress (1,873), ResolveRegion (2,531) and ReverseGeocode (3,784) are unchanged. The new 200 cases pass 27 before and 28 after, including 7 and 8 of the 78 house cases. A baseline rerun showed zero response drift. The absolute run still fails the pre-existing Rue de Rivoli must-pass case, and grpcurl's documented timeout reproduced in the RPC integration test. The 1,000 unscored address samples change in 7 results, all to houses in the same country; their accepted snapshot was not replaced. These are coarse, landmark-biased coverage checks, not an accuracy or speed claim.

Gold independence: the registries the gold corpus takes expectations from are already index inputs for their countries, through Overture's address theme (NAD for the United States, BAG for the Netherlands, BAN for France per département, Kartverket, swisstopo and CartoCiudad) and OpenAddresses (BAN, BAG, CartoCiudad and Kartverket again). A registry case there measures fidelity to the register, not independent accuracy. Overture's places are read in none of those countries, since Overture covers them.

Lookups

Lookups return candidates in the JSON format the resolver reads (Nominatim's search, details and reverse fields); this does not make Nominatim a supported backend. The service renders one search's candidates in English and, when it needs to corroborate a town written in another language, in local names, without searching twice.

It parses with the service's one libpostal parser (serialized), matches places, streets, postcodes and house numbers like Nominatim's all-terms search, and recognizes postcodes from the index when libpostal mislabels them. The resolver uses those repairs for corroboration only when no postcode was parsed, the inferred code does not come from a requested house number, road, unit, floor or P.O. box, and reparsing preserves the primary house number and road without duplicate labels. Stated country and region fields remain constraints. A separately comma-delimited state is not rewritten as a town. It reads Japanese block addresses (東京都港区芝公園4-2-8 is chōme 4 of 芝公園, block 2, house 8) and Chinese road addresses itself, since libpostal splits them. Streets are looked up in the named town, then a same-named county, then a same-named city-province (Istanbul), then by postcode and near the postcode's centre. It reports each house's own postcode, as the source data gives it. It remains experimental (ReverseGeocode reads houses and streets and ResolveRegion simplified polygons, see Reverse geocoding). It reads importance through cached per-segment fast-field columns and checks exact names against hashes folded at startup; Tantivy's order_by_fast_field reopened every segment's column per query and cost more than the search itself. The 12 GB index (Tantivy, house-number FSTs and places.tsv) is memory-mapped; in an earlier measurement 1.3 GB of it was resident after 20 seconds of load. On an 18-core laptop shared with other work (load average about 13), the service answered the 1,000 address samples at about 360 requests per second over one connection and 1,600 to 2,100 over eight (p99 39 ms). The same build calling a separate serve_index process over HTTP, on the same index, answered about 220 and 940 to 1,290 (p99 63 ms), with identical responses. The service's memory footprint was 3.5 GB, against 6.0 GB for the two processes, which each loaded libpostal's models. Per gold query it searched the index 1.8 times on average, where the HTTP version made 3.2 calls: one search now serves both languages. These are single measurements on one machine, not capacity guarantees.

A later round of changes was measured on the 11 GB world index above, with 808 sample queries over one connection and over 16, alternating the old service on the index as built (319 segments) with the new one on a copy merged to four segments, on a busy 18-core laptop. Responses were identical. With the parse cache off: 170 to 806 requests per second over one connection (p95 21.9 to 3.9 ms), 1,516 to 5,512 over 16, and 8.2 to 1.4 ms of CPU per request. Three changes account for it, in order of size: merging the segments (CPU 8.2 to 3.0 ms; the merge happens in the builder, so this needs a rebuilt index), filtering a lookup's document kind in the collector (3.0 to 1.4 ms: a kind:s term has 40 million postings that Tantivy walked block by block for every street lookup, which took one name from 0.007 ms to 0.7 ms; the kind is read from which fast columns a document has, so existing indexes gain from it too), and running a request on one blocking thread instead of one hop per parse and search. The same comparison between the two release images in Linux containers (cache off, index bind-mounted from the host) gave 109 to 599 requests per second over one connection (p95 33.5 to 5.3 ms) and 957 to 2,850 over 16, with resident memory 5.0 GiB before and 4.2 GiB after. With the default parse cache the same run on the host gave 875 and 8,724 requests per second, but the 808 queries repeat there, so that number flatters real traffic; within one request alone the cache saved 0.6 of about 2.3 libpostal parses. Startup with the world index dropped from 5.9 to 2.7 seconds (models load while the place table is read, and the table is parsed on all cores). A country filter in the collector measured no gain, and mimalloc gained 1% to 3% throughput for 0.75 GB more memory, so neither was kept. These are single measurements, not capacity guarantees.

A third round changed only how lookups run, not what they return. A lookup seeks the document runs of its kind (the builder writes places, streets and postcodes in 10 to 20 runs per segment), so a place's name no longer walks the millions of streets that share its words. The country filter reads a per-document country table built at startup (2 bytes a document, about 0.1 GB for a world index) instead of intersecting with a country term, whose postings tantivy's skip list walks one block at a time. A request remembers its own lookups (over half of them were repeats: the same town for every fallback and street scope), existence checks stop at the first match, and a place's English name is located once at startup. On the same busy laptop and world index, with the parse cache off, the 1,000 address samples took a mean of 0.8 ms instead of 2.4 ms over one connection (p99 5.3 ms instead of 23 ms; 1,240 requests per second instead of 410) and 5,000 to 5,400 requests per second over 16 instead of 3,100 to 3,300, where libpostal's single lock now sets the limit. With the parse cache on and the samples repeating, 16 connections gave 16,000 requests per second instead of 4,400. The gold suite's 3,732 cases returned identical responses, with total SearchAddress time down 63% (p99 31.5 to 10.7 ms), and tests/suggest.rs typed prefixes again took p50 82 µs instead of 125 µs (p99 3.4 ms instead of 4.6 ms). These are single measurements, not capacity guarantees.

In one run against the October 2026 Overture release with the OSM, OpenAddresses and GeoNames sources above, the gold suite passed 1,038 of its first 1,280 cases (231 of the 286 held out), against 992 for the index before the October 13 data and parsing work. On the 2,875-case version it passed 2,136 (433 of the 582 held out), including 545 of the 926 damaged addresses: a typo in the street (2 of 95) and a wrong postcode (10 of 95) were its weakest kinds, since streets then matched only on exact (canonicalized) words. Lookups now correct a street with one misspelt word (within one edit, same first letter, or a misspelt street type) when a resolved town or postcode holds a street with the other words and the house number. The service accepts a candidate whose postcode disagrees with the requested one when its house number, road and town all agree, after every candidate holding the postcode has been refused, and returns it with goodMatch false. With both, and 32 Census expectations corrected (their five-digit house numbers had been read as postcodes), the 2,875 cases passed 2,308 (459 of the 582 held out), including 701 of the 926 damaged addresses: 82 of 95 street typos and 73 of 95 wrong postcodes. One must-pass case regressed with them: "10 Rue de Rivoli, 75001 Paris, France" now answers number 10 of the same street in 75004. Of the original 247 cases it passed 239; Nominatim 5.3.2 with January 2026 OSM data passed 227, and the only case Nominatim passed and the index did not is a Dubai boulevard whose street data names a smaller locality than Dubai. Of the 405 address cases (Wikidata landmarks in 90 countries and registry addresses from France, the Netherlands, the United States, Norway, Switzerland and Spain), it passed 286. Most remaining failures are data gaps (streets or villages missing in OSM-only countries) or the service rejecting the index's right first answer on libpostal's parse ("Addis Ababa, Ethiopia" parses as one city name); those critical ones are labelled in tests/gold.seeds.jsonl. On the 1,000 address samples, the index answered 827 (Nominatim 855) and 143 at house level (117). Where the sample's country is known, it chose the right country for 97.3% of its answers (Nominatim 97.5%) and claimed a good match in the wrong country 14 times (Nominatim 14). To keep wrong countries down, a lookup answers a street with nothing to place it only when at most three same-named streets with that number exist, all in one country. It answers a place name without a country only on an exact name match, and a bare postcode without a country only when one country uses it. These are single runs on curated and sampled inputs, not production accuracy.

Geographic evaluation

A Senzing model trial on the same full index and 4,188 gold cases led to the corroboration and comma-boundary fixes above. With the default model, SearchAddress passes rose from 3,656 to 3,670; with Senzing's v1.2.0 parser bundle, from 3,604 to 3,638. Neither repair comparison introduced a newly failing case or a worse failure class. Switching models after the fixes still caused 86 newly failing cases and 44 worse failure classes, so the default model remains selected. These are counts from one corpus, not production accuracy estimates; the models, expectations and index were unchanged within each repair comparison.

The evaluate binary (src/bin/evaluate) measures geographic correctness of the service and its index through the service's RPCs. It is for backend, data and consumer changes; it does not measure throughput. Build it with the service:

cargo build --locked --release --bin opengeocoding --bin evaluate
Suite Command Needs
PR (CI) cargo test --locked (or target/release/evaluate check) Nothing; scorer, diff gate, corpus and snapshot checks
Smoke target/release/evaluate run --suite smoke --index out --out eval-results/smoke Index, models
Pre-release target/release/evaluate run --suite gold --index out --out eval-results/gold Same
Address snapshots target/release/evaluate run --suite addresses --index out --out eval-results/addresses --compare tests/snapshots/addresses.tsv Same
Compare target/release/evaluate diff BASELINE CANDIDATE Two saved runs or snapshots
Re-score target/release/evaluate report RUN_DIR --rescore A saved run; lists cases whose class changed

run starts target/release/opengeocoding (or SERVICE_BINARY) on the index in --index DIR (or INDEX_DIR) with no other environment than PARSE_CACHE=0. It sends each case --repeat times (default 3) and records the fastest, outside the rate limit and budget; diff opens with a speed section comparing those latencies in aggregate, one table each for SearchAddress, SuggestAddress (the full and name checks) and ReverseGeocode (at the point each single-answer case expects, so a house where the case is one). Passing speed reports keep the headline visible and collapse the details; slowdown reports stay expanded. The speed check fails when the total, p50, p90 or p99 of any table is more than 10% slower (not part of diff's exit code; CI reads the section's <!-- speed: pass|fail|skip --> line), and skips sides timed with different --repeat. Three runs of one binary on a cluster node agreed within 2% in total and 5% at each percentile, single cases only within about 30%, so single cases and the max are not gated; a laptop's totals moved by tens of percent. It rate-limits its RPCs (--max-rps, default 20) and stops after a request budget (--budget, default 20,000) when --rpc targets an external service; the local service it starts has neither limit unless you pass them. It counts each case's index searches from the service's /metrics, since cases run one at a time. For direct checks (--no-direct skips them) it loads the same index and its own libpostal parser, about 4 GB besides the service's. --rpc URL targets a running service instead, one request per case; search counts are then unavailable, and direct checks run only with --index. Results are checkpointed per case (--resume); the process is stopped on exit, including after Ctrl-C. No request is retried by the evaluator.

Each run writes manifest.json (a hash of the index's meta.json, which names its segments, consumer commit and binary hash, libpostal model and reference-data hashes, cache state and limits), results.jsonl (full responses, index search counts, direct-index evidence) and report.md. Label the backend with --backend-label, e.g. image digest and configuration. A code or configuration comparison requires the same built index; a data or import change is a separate experiment.

Gold corpus

tests/gold.jsonl is generated by evaluate build-gold from the curated case list in tests/gold.seeds.jsonl (one line per place or construct: queries, cohort, tolerances, must-pass and known-failure labels). Expected coordinates, countries, regions and components come from Wikidata, Base Adresse Nationale, PDOK/BAG, the US Census geocoder, postcodes.io, Kartverket (© Kartverket, CC BY 4.0), swisstopo's building address register (© swisstopo) and CartoCiudad (© IGN/CNIG, CC BY 4.0), never from Nominatim or this service. Sampled cities, towns and regions in about 240 countries and territories (some written as production writes them: "Stratford, CT", or a bare unique name) were picked from GeoNames by population and production weight, sampled registry addresses from Overture, and landmarks from Wikidata items with a street address; GeoNames and Overture only chose the cases, and their expectations come from the sources above. A place in a territory with its own ISO code (Puerto Rico, Réunion, a French overseas department) accepts that code or the sovereign country's.

200 landmark cases tagged house-coverage (20 each in the United Kingdom, Vietnam, Thailand, South Africa, the Philippines, Malaysia, India, Egypt, Nigeria and Ireland, where the coverage audit found potential additions) were drawn in a seeded order from Wikidata items with coordinates and a street address that contains a digit, at least 8 sitelinks in the United Kingdom and 1 in India and Nigeria, where bulk-imported bank and school items dominate. The query is the address, then the containing division and the country when the address names no country. 78 cases whose address states an unambiguous leading house number require that house and number, retaining the pre-selected 1 km landmark tolerance; the rest require a street and accept a house. These tolerances measure coarse coverage, not entrance accuracy. The four excluded leading numbers are a postcode, two kilometre markers and a Thai route number. Expectations were regenerated from Wikidata, not the new source or a geocoder. A landmark may be one of the places that source lists, so these cases favour it over residential addresses. Sixteen critical failures shared by both indexes were triaged and labelled in the seeds (missing streets, ambiguous localities, consumer rejection and one conflicting Wikidata address/point); no expectation was loosened.

The degraded cohort (929 cases) takes registry-verified addresses and changes one thing in the query: no country, no postcode, a typo in the street or town, a wrong postcode, another town with the right postcode, a neighbouring country, the parts reversed, all capitals or lowercase, or an abbreviated street type. The expected answer stays the registry's address, re-verified by build-gold. Edits that leave the address consistent expect goodMatch true; contradictory ones (typos, wrong postcode, town or country) accept either goodMatch value, since whether a repaired match is "good" is not decided. Cases are tagged with their kind (typo_street, wrong_country, ...). build-gold looks each registry reference up once, one registry at a time in parallel, and reads Wikidata in batches of 50; the 2,894 cases took about a minute to regenerate. It covers cities in many scripts, street addresses, postcodes, regions, ambiguous names, rural places, borders, fallbacks, malformed input and no-result input. Each case records an expected country, a resolution range, an area (point and radius, or ISO 3166-2 code), relevant components, a goodMatch expectation, provenance, licence and review status. Variants share a location key so they are not counted as independent places. Roughly one in five locations is held out by a stable hash and reported separately; keep it out of tuning. Review status is source-verified; human review is pending.

Cases are scored as pass, confidence_mismatch, too_coarse (a fallback never passes an exact-address case), abstain, too_fine, wrong_component, wrong_region, near_miss, far_miss (>100 km), wrong_country or error. Critical classes are listed individually, flagged when goodMatch was true. For failures the evaluator queries the index directly (raw, structured, reverse, details) to hint whether the backend lacked an acceptable candidate or the consumer rejected one. SearchAddress, ResolveRegion, ListRegions and SuggestAddress are exercised.

Every case with a place, postcode or address to find, except the degraded cohort (repairing a damaged address is SearchAddress's job), also types its query into SuggestAddress (full): it passes when one of the 5 suggestions is in the expected country, of a type of the expected level (address for a house; street, or address when the case accepts a house; postcode; country; any place type otherwise) and within the expected radius. Where the text before the first comma is the place's English Wikidata label, build-gold adds a suggest check of that name alone (name): the located Wikidata items with that label and more population (P1082) are listed in larger, and the place must come no later than right after them (rank). So Paris must suggest Paris, France first and Paris, Texas no later than second; a name with five or more more-populous namesakes is not checked. Both are classed pass, ranked_low, missing or error, reported per cohort with every name out of population order, and add up to two RPCs per case. In one run on a world index (Overture 2026-09-23.1), full passed 943 of 1,947 cases (5 of 721 addresses: a complete address with its postcode and country is rarely completed) and name 670 of 829. Every case with a single expected place and point also asks ReverseGeocode at that point (for a place of a locality or finer) and ResolveRegion (for a place in a known region, at region level or finer). ReverseGeocode passes when its first result is in the expected country and, where the case lists regions, in one of them. For a locality case with a suggest name (its English Wikidata label), the result must also name that place among its locality and region names, compared after folding case and accents: a house or street of the next village, or of a district that has no locality under it, is wrong_place. Spellings that differ from the label ("Townsville City", "Jabal us Saraj") count as failures too. On the 2026-09-23 world index this fails 91 of 3,705 checked cases that passed before; they fail on both sides of a comparison, so the relative gate ignores them. Runs from before this check saved no place names. Their answers are not checked for it, and diff does not count wrong_place against either side when one side has no place names. So a gate run whose base predates the check sees no wrong_place change. ResolveRegion passes when one of the regions it returns is an expected one. Both are classed pass, empty, wrong_place (ReverseGeocode only), wrong_region, wrong_country or error (an empty answer is the smaller failure), reported per cohort, and add up to two RPCs per case; a case's ReverseGeocode request is the one that diff times. Cases near a border can fail for want of a closer locality; the check gates change, like the others, so only a case that passed on the base and fails on the head counts. evaluate report --rescore re-scores them against the current corpus. Connect protobuf answers are decoded into the contract type and must equal the Connect JSON answers field for field. grpcurl runs as a known failure and is reported, not skipped.

known_failure labels record triaged baseline failures with their attribution. They stay visible in every report, and a label is only removed after a fix is verified. Do not weaken expectations or regenerate them from a run.

Redundant parts, stated countries and stated regions

Queries built by joining street, postcode, town, region and country with ", " repeat themselves ("San Francisco, US, United States", "Kyiv, Kyiv, Ukraine", "Madrid, Madrid 28007, ES, Spain") and carry codes that read as another place (IN is Indiana as well as India). 218 cases from seeds in tests/gold.seeds.jsonl (tags redundant_token, region_evidence, postcode_evidence, same_name_elsewhere, us_state_zip) cover this: Wikidata places with the repeated forms as variants, same-name pairs with a stated country (Delhi, United States accepts a US Delhi or no answer, never India), and towns placed by a state, region, postcode or district ("Encino, California", "Temple, PA 19560", "La Paz, B.C.S", "Barrow Street, Dublin 4, Irland"). What fixed them:

  • A part that repeats another is dropped before parsing (without_redundant_parts): the same text, a town beside the same town with its postcode, a country by its code beside its name. A country repeated as the whole query ("Luxembourg, Luxembourg") is tried as written first, since it may name the town of that name. A name written in two scripts is not collapsed.
  • A region that only repeats the town ("Tokyo", "Delhi") no longer stands for the country, and a country override needs the answer's own road and house, or a postcode in its country's shape and not the stated one's; a digit or a street in the query is no longer enough.
  • A suburb the index files under its town ("Blairgowrie" in Johannesburg) is the town stated when no road narrows it and the region stated fits; a city is not made ambiguous by a village of its name; a street found only because it is named like the stated state is not an answer.
  • A name and its region (or, with no region stated, its country) that the parser filed in the wrong fields, a suburb in a "town" that is the province ("Kriel, Mpumalanga"), a town and a state code fused into a road ("Riverside, CT"), still name the candidate (named_in_region).
  • A written region narrows the town lookup before the top 100 by importance is cut ("Temple, PA"), a three-letter ISO 3166-2 code ("B.C.S") is read as its region, "Dublin 4" is the town Dublin, and a town and postcode that name a place in another country outweigh a coincidence of house and road ("2560 Nidau").
  • A postcode the index tags with the stated country but sites in another ("28007" of Spain, placed in Niger) does not veto the stated town. A postcode in a different region does not outweigh the stated town in the stated region when that town is a candidate ("Multan, 66000, Punjab").
  • A town the whole parse already split out stays the town when the unsplit street part also contains it ("Kutztown Rd. Temple"). A leading two-letter region code is tried after the place ("NJ, WILLIAMSTOWN").
  • The Gaza Strip and the West Bank answer as Palestine: the index has no PS country, and files each as a country of its own.

Four short strings the service answers with nothing, where the place is plain, are seeds with known_failure labels. us-fl-gainesville (a hyphenated country-state-town slug) and Flemish (a demonym) should stay empty: reading them would be a rule for one producer's identifiers and an open-ended list of adjectives (Flemish Region answers). stadt oldenburg and 9e arrondissement de paris are worth recovering: they answer with a country (Stadt Oldenburg, Germany, 9e arrondissement de paris, france), and fail only because a bare name answers on an exact name match and these are exact after dropping stadt or after the index's own 9th Arrondissement. Neither is changed here; free text stays unmatched.

Known limits: Atlanta carries the Slovak name "Athens" in the index and outranks Athens-Clarke County, so Athens, Georgia answers Atlanta (labelled known_failure).

Registry-first samples

The gold seeds above were picked from Overture, the index's own data, and then checked against a registry, so a registry address the index lacks was never sampled and recall cannot be measured. tests/registry/ holds samples drawn from the registry first. evaluate check and cargo test validate each tests/registry/*.jsonl; run --corpus tests/registry/us.jsonl scores one. They are part of the gold suite: run --suite gold, diff and report read tests/gold.jsonl and then tests/registry/*.jsonl unless --corpus names a file, evaluate gold-corpus --out FILE writes the suite as one file (the eval image carries it as /app/tests/gold.jsonl, so the CI run scores both), and the run manifest hashes the files in that order. diff compares only case ids both sides have, so the pull request that adds a corpus is not gated on it. Candidate-only cases appear in an Added cases table with passed/checked counts and success rates by cohort and RPC; unscored searches are excluded, and SuggestAddress counts each applicable check. Those rates describe the new cases, not a base-to-head improvement or independent-location accuracy. No case is must_pass; the known failures are labelled.

United States (us.seeds.jsonl, us.jsonl): the USDOT National Address Database, release r24 (public domain, a work of the federal government; the USDOT disclaims accuracy and the data is not for mailing lists). The 8.5 GB download is a Deflate64 zip that Apple's unzip and Python cannot read; use 7-Zip. The text file is 42 GB and 99 million rows, 89.6 million of them plain, live addresses with a house number, street, five-digit ZIP and a city.

target/release/evaluate sample-us --nad NAD_r24.txt --seed us-2026-10 --out tests/registry/us.seeds.jsonl
target/release/evaluate build-gold --seeds tests/registry/us.seeds.jsonl --out tests/registry/us.jsonl --nad NAD_r24.txt

The draw keeps each row's rank by sha256(seed|uuid), so it does not depend on file order, and rerunning it on the same release gives the same seeds byte for byte (about 4 to 8 minutes; build-gold about 80 seconds). build-gold finds every sampled UUID in the file again and copies its point, house number and ZIP, and fails if one is gone (a new release needs a new sample). The sample:

  • 300 house addresses and 100 units (Apt 4B) whose placement the registry states as building, entrance, interior, site, access or parcel centroid, in three density strata (urban, suburban, rural: 10,000 or more, 2,000 or more, fewer address points in the ZIP). Density is an address-count proxy per ZIP, not a census measure, and partly mirrors how completely a state contributed.
  • 150 us_broad house addresses of any other placement, scored at 0.5 km instead of 0.15 km (0.3 km for a parcel centroid), reported apart: 72% of the release has placement "Unknown", and nearly all of Texas, California and Florida are in it.
  • States in turn: the draw keeps up to 3,000 candidates per kind and state, and each stratum takes one location from each state in a seeded order until it is full, so a state's share does not follow how many rows it contributed (New York holds 6.5 million trusted rows, Pennsylvania 0.1 million). Rates are therefore closer to an average over states than over addresses.
  • One address per street and at most three per ZIP. A street field holding a unit or only a direction is dropped, as is a unit that is a word ("Office", "Rear", "Suite1") rather than a number or letter.
  • Rows that contradict the registry are dropped: a row whose state is not the one holding most of its ZIP's rows (some Virginia counties' addresses carry the neighbouring state's code), and an address the registry places twice, further apart than the case's tolerance.
  • The town is the first of the postal city, municipality, community and Census place that names one, written as people write it: "Salcha", not the Census "Salcha CDP"; "Wausau", not "Town of Wausau"; "Atlanta", not the postal code "ATL" or "ADK".
  • A damaged variant for half the locations, drawn evenly among the edits that apply to it: no postcode, country added, typo in the street or town, wrong postcode, another town in the same state, parts reversed, upper or lower case, abbreviated street type or direction, no commas, the state spelled out, ZIP+4, and for units #4B or Unit 4B. Variants share their location key and expect goodMatch true when the edit is consistent, either value when it contradicts itself.

Limits to repeat with any number from it: the 400 trusted-placement locations come from 29 states and the District of Columbia, at most 20 from one, and with the broad sample the 550 locations cover 45 states and the District of Columbia, at most 24 from one; Hawaii, Michigan, Mississippi, Nevada and New Hampshire have no usable rows in r24, and 16 states, Texas, Georgia and Ohio among them, appear only in the broad sample; NAD is aggregated from state and local data of uneven coverage (Florida and Georgia are almost absent); registry rows are public address points of any building, not occupants; and the index itself is built from sources derived from the same state and local registers, so a pass measures fidelity to the register, not independent truth.

Review. Done by the assistant that wrote the sampler, not by a person: review status stays source-verified, human review pending. Every location's point was checked against the Census 2020 ZCTA gazetteer (public domain): 543 of 545 lie within three ZIP radii or 15 km of their ZIP's centre; the two others are Gray, KY (inside the cloud of NAD points for its ZIP) and Long Beach Township, NJ (on Long Beach Island, 29 km from the ZCTA's internal point, where the index has the house 13 m away); five ZIPs that are not ZCTAs were left alone. Reading the queries and the failures found what the filters now drop: towns named "Unincorporated" or "Denton County", Census suffixes and three-letter postal codes as towns, word units, a street field holding a unit, a bare direction, a direction and type written twice ("West W Red Oak St Street"), and addresses in Lee, Buchanan, Frederick and Clarke counties, Virginia, filed under Tennessee, West Virginia or Maryland with Virginia ZIPs.

Triage. us.labels.jsonl holds one {"ref", "query", "known_failure"} per failing case; build-gold attaches it by registry UUID and query, so labels survive a resample and a label that matches no case is an error. Of the 16 failing base locations, none is matching: 10 are index data and 6 reference data. Each was rerun by hand (with the ZIP alone, with the index's locality, without the town):

  • Index data (10): the house is not in the index (Arlington, WA; Flemington, NJ; Lakeland, FL; Evansville, IN; Hollywood, FL; the Lakeland house is not in OpenAddresses either), or only its street is (Cheshire, MA; Atlanta; Budd Lake, NJ; a Seminole reservation street in Hollywood, FL; Leonard Lane, in Lee County, Virginia, given with a Tennessee town and ZIP).
  • Reference data (6): three units the register places at their own point, 210 m, 280 m and 1.9 km from the building the index answers (mobile-home lots); a unit 252 m off; a house the index files under Irma, 262 m off; and a house the index has at the register's point in Clarke County, Virginia, which the register gives a West Virginia town and ZIP.

Of the 300 damaged variants, 8 fail and are labelled: 6 share their base case's cause; "410 Welcome Springs Lane, Talladega, AL 35125" has a wrong ZIP on an address the index files under Emauhee, two contradictions, and answers the stated town; and "832 Napoleon Avenue, Baton Rouge, LA 70115", the one critical result, answers Baton Rouge, 120 km from the New Orleans address, because the index holds that house without a postcode and does not know ZIP 70115, so nothing contradicts the stated town.

Matching fixes found through this sample. Each failing case was rerun by hand and the cause fixed where it was the service's, not the data's or the register's:

  • Comma parts bound the parse in more US shapes: a town swallowed into the road ("4724 3rd Place Northwest, Washingotn, DC"), a road read as a suburb ("2219 Pacific Coast Highway, Lomita"), a county part between street and town ("15 Westervelt Place, Essex, Irvington Township, NJ"), a trailing country part, and "street, STATE ZIP" with no town, where libpostal took the street type for the town ("701 GREENWOOD Avenue, OH 45229") or the state for part of the road.
  • A reversed address whose first part is the country ("United States, ND 58104, Fargo, 4122 Osgood Parkway South", "France, 76160 …, 258 Rue …") is resolved in the usual order. The resolver turns the query around itself; Parser::parse does not, since the structured re-queries are written country first on purpose.
  • A unit left at the end of the road ("#B", "Apt 10A", "Suite 200") is moved out of it; "Ste Foy" keeps its "Ste".
  • A two-letter US state code that libpostal took for a country ("Dover, DE 19901" as Germany) is a state when the index knows the ZIP in the US and not in that country.
  • A postal town within 10 km of a house whose number, road and ZIP match (house_despite_town) is a confident answer: Salcha for a house the data files under Aurora Lodge, Peoria for Bellevue. Further away it stays goodMatch false.
  • "Township", "Borough" and "Town" qualify a US place name ("Long Beach Township" is Long Beach, "City and Borough of Juneau" is Juneau), for the resolver's town check and the index's place lookup.
  • A misspelt street is checked against every street type or direction within an edit, and against name words too ("southewst" is as near "southeast" as "southwest"; "adlee" is "allee" or "adele"), and the resolver accepts the road the index corrected ("West 54th Srteet").
  • A US house with only its state to place it ("1516 RUTH Avenue, OH") is answered when the state holds that number on one street of that name, and with a contradicting ZIP and no town ("7413 Janes Ranch Road, TX 78747") the same lookup answers with goodMatch false. A house number that is also a ZIP ("15411 Woodcliffe Trail, IN") is no longer taken for the ZIP when a state is stated. A road alone no longer outweighs a contradicting state code ("10 Elm Street, MA" is not Newark's), a structured re-query does not place a street that only a state places, and a stated state is not the country of the same code ("MA", Morocco).
  • With a stated country that contradicts the rest ("715 Meehan St, Santa Maria, CA 93454, Canada"), the house the query names without the country is answered when its number, road and postcode are the stated ones.
  • The evaluator equates a ZIP+4 answer ("50625-8016") with its ZIP, and build-gold takes the last four-digit word of a swisstopo reference as the postcode ("Chieberg 1526 3855 Brienz" had house "Chieberg" and postcode 1526).

One local run against a world index built from the October 2026 sources (the gate passes on this corpus with these labels; a second run after the last change showed zero drift): 534 of 550 locations passed (97.1%, Wilson 95% interval 95.3 to 98.2), 386 of 400 with a trusted placement (96.5%, 94.2 to 97.9), 148 of 150 broad (98.7%, 95.3 to 99.6), 121 of 123 held-out base locations, and 292 of 300 damaged variants (97.3%, 94.8 to 98.6). On main before these fixes (commit 32db24a) the same sample passed 527 of 550 and 260 of 300. Across the whole gold suite (curated and registry, 3,759 cases) the fixes made 121 cases pass and none fail or fail worse; the curated US cases went from 384 to 441 of 460, and the 1,000 address samples changed in three answers, all finer or more confident and correct. By hand, the index held the address, or for units the building, for 6 of the 16 failing base locations, so recall is about 98%. These are a development run, not a published accuracy claim.

Netherlands (nl.seeds.jsonl, nl.jsonl): 684 locations and 342 damaged variants drawn directly from Kadaster's BAG GeoPackage of 2026-10-01, distributed by PDOK under CC0-1.0. The sampling frame contains 8,320,999 in-use residential objects with a point and postcode. sample-nl ranks the main-address number-designation IDs by seeded SHA-256, keeps the smallest 200 per municipality and suffix class, and selects one plain number and one with a house letter or addition on different streets in each of all 342 municipalities. There are 302 sampled numbers containing letters and 85 with additions (some have both). This weights municipalities equally and deliberately oversamples suffixes, not population or address counts; it is not a representative national accuracy sample. Of the 684 base locations, 129 are held out by the shared location-hash rule.

Download and retain the BAG GeoPackage: the URL moves every month. The file used here is 7,818,600,448 bytes, SHA-256 b56b3a1a9156d2c7b1d9a6a15cb227f5d126116ae6556cbad668964e988dff0f. Prepare a CSV with DuckDB's spatial extension (1.5.6 for this sample); this is offline corpus preparation, not a service dependency:

curl -fL https://service.pdok.nl/kadaster/bag/atom/downloads/bag-light.gpkg -o bag-light-2026-10-01.gpkg
duckdb <<'SQL'
INSTALL spatial;
LOAD spatial;
SET threads = 4;
COPY (
  SELECT nummeraanduiding_hoofdadres_identificatie AS id,
         openbare_ruimte_naam AS street, huisnummer AS number,
         huisletter AS letter, toevoeging AS addition, postcode,
         woonplaats_naam AS city, bronhouder_identificatie AS municipality,
         round(ST_Y(point), 8) AS lat, round(ST_X(point), 8) AS lon
  FROM (
    SELECT *, ST_Transform(geom, 'EPSG:28992', 'EPSG:4326', always_xy := true) AS point
    FROM ST_Read('bag-light-2026-10-01.gpkg', layer := 'verblijfsobject')
    WHERE status = 'Verblijfsobject in gebruik'
      AND list_contains(string_split(gebruiksdoel, ','), 'woonfunctie')
      AND geom IS NOT NULL AND postcode IS NOT NULL
  )
) TO 'bag-addresses-4326-2026-10-01.csv' (HEADER);
SQL
target/release/evaluate sample-nl --bag bag-addresses-4326-2026-10-01.csv --seed nl-2026-10 --out tests/registry/nl.seeds.jsonl
target/release/evaluate build-gold --seeds tests/registry/nl.seeds.jsonl --out tests/registry/nl.jsonl --bag bag-addresses-4326-2026-10-01.csv
target/release/evaluate run --corpus tests/registry/nl.jsonl --index out --out eval-results/nl

The prepared CSV is 672,460,226 bytes, SHA-256 37bc9a5318c48cefff8f117a5dcac73e93843dd27876cb43c479600a9e162cdb. Each generated case records its filename, checksum and coordinate conversion. build-gold --bag re-reads every sampled ID from that CSV, rejects a missing or duplicate ID, and copies its house number (letter attached, addition after a hyphen), street, postcode and point. No geocoder selects or verifies an expectation. Points are rounded to six decimal places and checked within 150 m. Municipality codes are sampling tags, not inferred ISO regions, so these cases do not check ResolveRegion.

One location per municipality also gets an edit, balanced among eleven applicable kinds: no country, no postcode, uppercase, lowercase, no commas, reversed parts, a space in the postcode, a street or town typo, or another sampled postcode or town from that municipality. Variants retain their location and hold-out assignment. Consistent edits require goodMatch true; typos and contradictory parts accept either value. The GeoPackage publishes only one address per addressable object; secondary addresses, moorings, pitches, non-residential objects and objects not yet in use are outside this frame. BAG covers the European Netherlands, not the Caribbean Netherlands. Dutch index addresses share BAG upstream data, so passes measure registry fidelity, not independent real-world accuracy.

Two same-binary runs on the full world index (builder 83ef195a69f42f93, Overture 2026-09-23.1 with the local optional sources) returned identical results for all three checked RPCs. SearchAddress passed all 684 base locations and 337 of 342 variants; the five failures were too-coarse answers, four to street typos and one to a query without a postcode. There were no critical results and no new known-failure labels. SuggestAddress passed 676 of 684 full base strings and 185 of 342 variants. ReverseGeocode returned the accepted country for all 1,026 points; this does not check the house or locality it names. The standalone Dutch gate passes, and grpcurl's documented known failure reproduced. A full-suite comparison kept all 6,919 existing cases' answers unchanged across all checked RPCs; both absolute runs still fail the pre-existing Rue de Rivoli must-pass case. Its same-consumer timing comparison flagged slowdowns above the 10% budget for all three timed RPCs, so there is no clean speed result. No service behaviour changed. These are development observations, not production accuracy or speed claims.

France (fr.seeds.jsonl, fr.jsonl): 505 locations drawn directly from the Base Adresse Nationale CSV export last modified on 2026-10-16 (Licence Ouverte / Open Licence 2.0), independently of Overture and the index. sample-fr keeps municipality-certified addresses with an entrance, building or parcel position, takes the smallest seeded SHA-256 hashes in each department, then selects five distinct communes and street names per department. Paris, Lyon and Marseille arrondissements have separate commune codes. All 96 metropolitan and five overseas departments are covered; other collectivities and Monaco are outside this sample. This balances departments, not population or urban/rural density, and favours communes that publish certified points; it is not a representative national accuracy sample.

target/release/evaluate sample-fr --ban adresses-france-2026-10-16.csv.gz --seed fr-2026-10 --out tests/registry/fr.seeds.jsonl
target/release/evaluate build-gold --seeds tests/registry/fr.seeds.jsonl --out tests/registry/fr.jsonl --ban adresses-france-2026-10-16.csv.gz
target/release/evaluate run --corpus tests/registry/fr.jsonl --index out --out eval-results/fr

The compressed export is 938,579,415 bytes, SHA-256 34bc460429e6b042986ea4296dc8c3f82b308cb3a2ac877e8ee3533d89303680. Retain that file to reproduce the sample: the BAN download moves with each release. Each generated case records the export filename and checksum. build-gold --ban re-reads the cited row by its BAN id; no geocoder chooses or verifies the expectation. House number (including its suffix), postcode, street and coordinates come from that row. The tolerance is 150 m, or 300 m for a parcel point. The sample does not infer ISO region codes from postcodes, so it does not check ResolveRegion. Overseas departments accept their own country code or fr.

Three locations per department also get one of ten edits, balanced among applicable kinds: no country, no postcode, uppercase, lowercase, no commas, reversed parts, a transposed pair of letters in the street or town, another sampled postcode, or another sampled town in the department. These 303 variants share their base location's expectation and hold-out flag. Consistent edits require goodMatch true; typos and conflicting parts accept either value. The sampler retains only 200 candidates per department before enforcing diversity, so a small department can yield fewer than five locations. No point or query is selected using a service response.

On one local full-index run (builder 0a3e9077e88a1702, Overture 2026-09-23.1 with the optional sources from the local build), 460 of 505 base locations and 267 of 303 variants passed; 86 of 98 held-out base locations passed. At introduction, six critical results at three locations were triaged in fr.labels.jsonl: compound town names lead to Couarde or Romain hundreds of kilometres away despite a correct structured house lookup, and a house BAN certifies in Grand-Santi is indexed within 2 m but under Suriname. Other failures remain visible and unlabelled pending diagnosis. They include spaced house suffixes (7 bis falls back to the street while 7bis finds the house), missing houses or streets, and damaged inputs. Expectations were not relaxed and no resolver behaviour changed. ReverseGeocode returned an accepted country for 504 of 505 base points; SuggestAddress found none of the 505 complete base-address strings. A same-binary, same-index repeat kept every class but changed the wrong house returned for the damaged Châlons-du-Maine query (La Torlière versus La Petite Torlière); the strict zero-drift check therefore failed. The full gold suite still fails its existing must-pass Rue de Rivoli case. These are development observations, not a clean comparison, production accuracy or independent truth: the index's French addresses and BAN share upstream data.

Norway (no.seeds.jsonl, no.jsonl): 714 road addresses and 357 damaged variants drawn directly from Kartverket's national Matrikkelen–Adresse CSV export of 2026-10-10 (© Kartverket, CC BY 4.0). The file contains 2,603,963 rows, including 2,569,425 road addresses. sample-no takes two different street names per municipality, ranked by seeded SHA-256 of the address UUID, from a pool of the smallest 100 hashes per municipality. All 357 Norwegian municipalities in the 15 counties are represented; Svalbard and Jan Mayen are excluded. This weights municipalities equally, not their populations or address counts. It includes 84 houses with letter suffixes and uses the registry's postal town, which need not be the municipality or nearest settlement.

unzip -p matrikkelen-adresse-2026-10-10.zip '*/matrikkelenAdresse.csv' > matrikkelen-adresse-4258-2026-10-10.csv
target/release/evaluate sample-no --kartverket matrikkelen-adresse-4258-2026-10-10.csv --seed no-2026-10 --out tests/registry/no.seeds.jsonl
target/release/evaluate build-gold --seeds tests/registry/no.seeds.jsonl --out tests/registry/no.jsonl --kartverket matrikkelen-adresse-4258-2026-10-10.csv
target/release/evaluate run --corpus tests/registry/no.jsonl --index out --out eval-results/no

Retain the national EPSG:4258 archive: its URL moves with each release. This archive is 151,708,361 bytes, SHA-256 d2ba050754e71aac18ffd572538ee97d43e3b00c543ef567dfc8a421d99bef7c; its extracted CSV is 812,385,818 bytes, SHA-256 02b2bbc896a65d4510cf8159d26eed3d3f777c8059cc011699b1b4dd4383910d. Every case records the CSV filename and checksum. build-gold --kartverket reads each cited UUID again and copies its house number, letter suffix, street, postcode and point, never a geocoder's answer. It rejects a projected-coordinate export rather than treating metres as latitude and longitude. EPSG:4258 (ETRS89) coordinates are used directly at the 150 m tolerance, not with a survey-grade datum transformation. Cadastral addresses without a road, rows with missing address components and invalid points are outside the sample. County numbers are tags, not inferred ISO region expectations, so these cases do not check ResolveRegion.

One of the two addresses in each municipality also gets an edit, balanced among the eleven kinds that apply: no country, no postcode, uppercase, lowercase, no commas, reversed parts, ASCII transliteration, a street or town typo, or a different sampled postcode or town from the same municipality. A variant retains the registry expectation and location's hold-out assignment. Consistent edits require goodMatch true; typos and contradictory parts accept either value. The source selects the cases independently of Overture and the service, but Norwegian index data can share this same upstream register, so a pass measures fidelity to the register, not independent truth or production accuracy.

On the same local full index as above, 706 of 714 base addresses and 331 of 357 variants passed; 130 of 131 held-out base addresses passed. There were no critical results, and no known-failure labels were added. The 34 failures remain visible and unlabelled pending diagnosis; many postcode-omission variants fall back to the postal town even when direct lookup finds the house. ReverseGeocode returned Norway for all 1,071 points. SuggestAddress found none of the 714 complete base-address strings and 24 variants. A same-binary repeat had zero result or class drift, though its one-request speed section flagged p50 increases of 20% for SuggestAddress and 14% for ReverseGeocode; these laptop timings are not latency guarantees. Comparing the existing 4,996-case suite with the expanded 6,067-case suite changed no shared results and passed the relative gate. Both standalone full runs still fail the existing Rue de Rivoli must-pass case. Sampler output was byte-identical on a repeat; expectations and service behaviour were not changed.

Switzerland (ch.seeds.jsonl, ch.jsonl): 520 addresses and 260 damaged variants from the official building-address directory (©swisstopo, its OGD terms), whose STAC item is dated 2026-10-16 and whose CSV asset was updated on 2026-10-15. The export contains 3,304,410 rows. sample-ch selects real, official, residential addresses, ranks their EGAIDs by seeded SHA-256, and takes 20 different municipality/street pairs in each of the 26 cantons from its smallest 500 hashes. The sample includes German, French, Italian and Romansh street names. Equal canton weighting is not population weighting; the eligibility filter is particularly restrictive in Valais (3,585 eligible rows) and Graubünden (7,010), so this is not a representative national sample.

unzip -p swisstopo-addresses-2056-2026-10-16.csv.zip '*.csv' > swisstopo-addresses-2056-2026-10-16.csv
target/release/evaluate sample-ch --swisstopo swisstopo-addresses-2056-2026-10-16.csv --seed ch-2026-10 --out tests/registry/ch.seeds.jsonl
target/release/evaluate build-gold --seeds tests/registry/ch.seeds.jsonl --out tests/registry/ch.jsonl --swisstopo swisstopo-addresses-2056-2026-10-16.csv
target/release/evaluate run --corpus tests/registry/ch.jsonl --index out --out eval-results/ch

Retain the national Swiss CSV archive, whose URL moves with releases. The ZIP is 143,331,394 bytes, SHA-256 0965c7271d3bc90ea6c6d46c68738e66dc860459847988ff7e697eb77eb169e8 (also the STAC asset's SHA-256); the CSV is 468,469,954 bytes, SHA-256 c8d6d5f8777656bcbc364ec867a8b7641d370e90cc97083021c1c4441f6bba82. Each case records the CSV filename and checksum. build-gold --swisstopo re-reads its EGAID and copies the house number, street and postcode. The LV95/EPSG:2056 coordinates are converted by swisstopo's official REFRAME service, once per location, sequentially with 100 ms between requests. This step needs network access; it is coordinate conversion, not address geocoding. The recorded conversion uses altitude zero because the CSV is two-dimensional, and the cases assert no height. Points are rounded to six decimal places and scored within 150 m. Planned, unofficial, non-residential and invalid-coordinate rows are outside the frame. The source's canton abbreviation supplies the CH-XX expectation, so SearchAddress, ReverseGeocode and ResolveRegion all check the canton.

Half of the locations get one of the same eleven edits as Norway, balanced among applicable kinds; the wrong postcode or town is another sampled one in that canton. Variants keep the same point, canton and location key. Consistent edits require goodMatch true; typos or conflicting components accept either value. No service response selects a case or supplies an expectation. Registry and index addresses can share upstream data, so agreement is fidelity to the register, not independent truth or production accuracy.

On the local full index, 504 of 520 base cases and 246 of 260 variants passed; 86 of 90 held-out base cases passed. All 30 failures remain visible and unlabelled: 19 wrong canton, five too coarse, three near misses, two confidence mismatches and one abstention. There were no critical results. Several wrong-canton houses are within 2 m of their source point but filed under a neighbouring canton (notably Schaffhausen under Zurich). ReverseGeocode passed 507/520 base cases and ResolveRegion 513/520; SuggestAddress found no complete base address and 18/260 variants. A Swiss same-binary repeat had zero result or class drift. The relative full-suite comparison passed, with all 6,067 shared classes unchanged, but repeated the already documented French La Torlière/La Petite Torlière wrong-answer drift and flagged SuggestAddress p90 +13% and p99 +14% in its one-request speed section. Both full runs still fail the existing Rue de Rivoli must-pass case. Sampler and full corpus regeneration (including a fresh REFRAME conversion) were byte-identical; re-scoring the expanded 6,847-case run changed no classes. These timings are not a clean performance comparison, and no service behaviour or expectations were relaxed.

Runtime fixes exposed by the registries

Against main at 1e3d786, on the same full index (builder 83ef195a69f42f93), the runtime fixes improve all four RPCs without a newly failing or worse case among 6,918 shared cases:

RPC Passing before Passing after
SearchAddress 6,186 / 6,847 6,299 / 6,847
SuggestAddress 2,063 / 6,919 4,936 / 6,919
ResolveRegion 3,300 / 3,332 3,311 / 3,332
ReverseGeocode 6,422 / 6,564 6,445 / 6,564

For the registry's complete base-address strings:

Country SearchAddress before → after SuggestAddress before → after
France 460 → 498 / 505 (98.6%) 0 → 475 / 505 (94.1%)
Norway 707 → 709 / 714 (99.3%) 0 → 691 / 714 (96.8%)
Switzerland 504 → 517 / 520 (99.4%) 0 → 509 / 520 (97.9%)

The fixes preserve the street and house when libpostal mistakes a compound town's postcode for another house number, retry spaced house suffixes against their indexed spelling, and scope full-address suggestions by postcode and postal town (which need not be a geographic locality). Ambiguous country/state qualifiers are not forced to a country. House ancestry is reconciled with a containing same-country region when both are available; a conflicting county polygon or inherited child region column no longer overrides the region itself. Duplicate same-postcode houses prefer proximity to the postcode and break ties by stable street id.

All coordinates, tolerances and expectations are unchanged. The four verified compound-town far_miss labels were removed and the French corpus regenerated from its pinned BAN export; the two Grand-Santi wrong-country cases remain labelled. Some source locality/suburb labels remain stale even when the corrected region and house point pass. Damaged-address suggestion rates remain only 40.9% in France, 39.8% in Norway and 41.5% in Switzerland, so these results do not establish general damaged-input coverage. The standalone gold run still fails the existing Rue de Rivoli must-pass case. Local three-request timings varied: some comparisons passed the speed budget, but the final same-consumer comparison flagged SuggestAddress at +16% total, +23% p50 and +15% p90. SearchAddress and ReverseGeocode stayed within budget. The suggestion speed gate remains unresolved; these laptop timings are not production latency or throughput evidence. Registry/index source overlap still makes these fidelity checks, not independent accuracy measurements.

Gates

run fails on a must-pass failure, an untriaged critical result or a transport regression. diff fails on any newly failing case, a worse failure class, a new critical result or a must-pass failure in the candidate. It lists newly passing cases for review and drift in unscored cases. SuggestAddress checks do not gate run; in diff a check that stops passing, or gets a worse class, fails like a search case. Accept a backend change only when both gates pass on the same corpus, consumer, models, reference data and fresh region cache. Report cohort changes as case counts: with about 250 cases, aggregate rates carry several points of uncertainty and per-country rows are individual evidence, not rates.

The prepare and gold jobs of .github/workflows/staging-publish.yml run the gold suite on every pull request that touches the service, its data or the corpus (not on main commits, whose images are built only so that a pull request's merge base has one). After the workflow pushes a commit's images, they send the pull request's merge base and head hashes to an authenticated endpoint of a private cluster, which evaluates both staging images on one full index (building it first from the head image's builder when it has none), then runs evaluate diff --relative on the two runs and returns the report (failing and worse cases open, the lists to review and the run details folded, a table of each RPC's passing checks on both sides and how many results changed, then a section only for each RPC whose results changed, and a gate line only when it fails; the run's id and step times are in the job summary); the workflow comments it on the pull request (also in the job summary) and keeps the untrimmed report, the run's status and the speed section as the artifact gold-<head>-<run>-<attempt> for 90 days, even when the run fails (the endpoint returns only the report, not each case's raw response), sets a gold-full commit status, and fails on any newly failing or worse case or new critical result. The report's speed section (SearchAddress, SuggestAddress and ReverseGeocode timings) goes into a comment and a commit status of its own, gold-speed, which fails, with an error in the job, when any of the three is more than 10% slower in total, p50, p90 or p99. --relative leaves out must-pass cases that fail on both sides, so the check shows change, not accuracy. To reproduce a run locally, use evaluate run on each side, then evaluate diff BASE HEAD --relative. No pull-request code runs on GitHub in this workflow, and the cluster holds no GitHub credentials. Pull requests by the owner, members and collaborators use the gold-trusted environment and run unattended; every other pull request waits in gold-approval for a reviewer, who should read the diff first, since approving it runs that author's code in the cluster. Both environments accept workflows from main only and hold the endpoint key (GOLD_GATEWAY_KEY) and URL (GOLD_GATEWAY_URL). While the run is queued or going, the gold-full and gold-speed comments show it (queued, then running with the steps seen so far and their times, as the endpoint reports them), so an older commit's report never stands in for this one's; the job polls the endpoint every 10 seconds and edits the comment on each change of step or every 30 seconds; its log has a timestamped line per change, a progress line every 30 seconds and a warning for each poll the endpoint fails to answer (it gives up after 60, 10 minutes). A run that fails or is lost says which step failed and after how long, in the comment, the job summary, an annotation and the commit status: a failed step is a failure of the run, not a verdict on the change. The endpoint reports only the step, not that step's log; giving it a log tail would be the next step, and it lives in the cluster, not here. Pushing again to a pull request, or closing or merging it (gold-cancel.yml), cancels its staging build and its run, including the one in the cluster. The workflow skips a commit whose merge base has no multi-architecture staging image (the commit statuses then read "Skipped" and pass), and says so in a comment of its own on the pull request, with the rebase that fixes it; the comment is deleted when a later push can run. The run's progress (queued, running, each step's time, or a failure) is that same status comment, updated in place; the report is posted as the gold-full and gold-speed comments, and the status comment is deleted after them. A run's start deletes an older commit's report, and a failed run leaves its status comment until the next run. A first index build takes hours and can outlast the job's time limit; the run continues in the cluster and later runs reuse its index.

Address snapshots

tests/addresses.tsv holds 1,000 real address and location strings sampled from business addresses, job postings and profile locations. They are spread across 125 countries and stratified by the granularity they target: full address, street, postcode, city, region, country and unparseable. They show how a code or backend change moves everything else; they have no expected answers and measure change, not accuracy. The file holds only the query strings and their geography. Source names, providers, coordinates and identities are removed, and so are imprint addresses and strings naming a company. tests/snapshots/addresses.tsv records the accepted behaviour, one row per case: country, level, coordinates (3 decimals, about 100 m), goodMatch, house number, road, postcode, locality, ISO and enriched regions, and the query. A changed answer is one changed row in a PR; git diff --word-diff shows just the changed fields. Run metadata (backend version and data date, consumer commit, binary, model and reference-data hashes) is in addresses.manifest.json beside it, so it does not clutter the row diff. Full responses stay in the run's results.jsonl. Tabs, newlines and other control or separator characters in values are written as backslash escapes.

target/release/evaluate run --suite addresses --out eval-results/addresses \
  --compare tests/snapshots/addresses.tsv
# after reviewing the changes, accept them
target/release/evaluate snapshot eval-results/addresses tests/snapshots/addresses.tsv

tests/snapshots/gold.nominatim.json is a reference run of the then 1,280-case gold suite through this service against the Nominatim 5.3.2 deployment it replaces (OSM data of 2026-01-26), taken on 2026-10-13 at 20 requests per second: 888 of 1,280 cases passed (227 of the original 247, 194 of the 286 held out). It holds the run manifest, the class counts and every case's full result except timings and backend traces, one case per line. It is kept for reference and is not regenerated when the gold set changes; compare a run with it using evaluate diff tests/snapshots/gold.nominatim.json RUN_DIR (cases added since then show as new). evaluate snapshot RUN_DIR FILE.json writes this format.

The diff counts per target granularity which cases gained or lost a result, changed country, became finer or coarser, moved more than 1 km (or less, but visibly in the snapshot), flipped goodMatch, or changed components or regions, and lists each one. --fail-on-drift turns any change into a failure. Rerunning an unchanged binary against the same backend produces no drift. A changed answer is not automatically better or worse. Snapshots depend on the backend's data; regenerate the baseline on the target backend before comparing code changes there.

The sampler that queries production data is kept outside this repository; it reuses opengeocoding::eval for TSV escaping and hold-out assignment. Other corpora are JSONL in the gold format, or TSV with at least id and query columns, passed with --corpus. Private ones (e.g. replay sets with earlier production geocodes) use the same tooling; keep those and their runs out of this repository.

GitHub-hosted runners have neither a world index nor models, so CI runs only the offline checks in cargo test. Run the model-backed suites locally or on a runner with the index and models mounted.

GHCR publishing

.github/workflows/docker.yml runs on v* tag pushes, which release-please creates when its Release PR is merged (it needs the RELEASE_PLEASE_TOKEN secret, because tags pushed with GITHUB_TOKEN trigger no workflows). It runs the existing checks first, then uses Docker's pinned reusable workflow to build on native amd64/arm64 runners, push to ghcr.io/mokto/opengeocoding, and assemble and verify the multi-platform manifest. Pull requests do not publish to that image.

Separately, staging-build.yml builds the eval image on native amd64 and arm64 runners (the runtime image plus the evaluate binary and tests/gold.jsonl) for every pull request (forks included) and every main commit, with no secrets, and staging-publish.yml pushes it to ghcr.io/mokto/opengeocoding-staging from the default branch without running it: sha-<commit>-amd64, sha-<commit>-arm64 and the multi-platform sha-<commit>. These images are untrusted builds for evaluation environments, not releases; set that package's visibility to public after its first push so evaluators can pull it anonymously. The publish workflow only triggers once it exists on main, so it cannot be tried from the pull request that adds it. Old tags are not pruned yet.

The repository is Mokto/opengeocoding. The Rust crate and service executable are opengeocoding, and the GHCR image is ghcr.io/mokto/opengeocoding.

A v1.2.3 Git tag produces 1.2.3 and latest; pushes to main publish nothing. Publishing uses the workflow's GITHUB_TOKEN with packages: write, so no personal access token secret is required. OCI labels link the package to this repository. Crates and GitHub releases are not published.

After the image, the same workflow publishes the Helm chart to oci://ghcr.io/mokto/charts/opengeocoding, pinned to that image: a v1.2.3 tag gives chart version and appVersion 1.2.3.

GHCR can initially create packages as private even for public repositories. After the first successful push, set the package visibility to public if anonymous image pulls are required.

Reference data and normalization

Country aliases use public Unicode CLDR and GeoNames data. Administrative-region hints use GeoNames; the Wikidata items of the ISO 3166-2 subdivisions Overture lacks (iso3166_2, below) come from Wikidata (CC0). Sources, revision and download hashes are recorded in data/sources.json; the checked-in data allows offline reference generation during normal builds.

To refresh it, run cargo run --locked --bin update_reference. This requires network access and changes the checked-in dataset; review it as a data update. Character classes come from the Unicode Character Database version pinned in the generator (currently 16.0.0), not from the toolchain.

An optional build-time REFERENCE_DATA path can supply a custom reference JSON. Search overrides are empty by default. No private deployments, endpoints or address overrides are included.

Transliteration uses BSD-3-Clause deunicode, not Python Unidecode. Outputs differ for some scripts and symbols. In a migration comparison of 7,067 geographic/sample strings, 55 normalized outputs differed, including Hebrew and Japanese forms. Matching normalizes both sides consistently, but these differences still need real-address accuracy validation before replacing another service.

License

Project source: PolyForm Shield 1.0.0. Any use is allowed, including commercial use, changes and redistribution, except providing a product that competes with the software or with a product the licensor provides using it; a free product can compete too. There is no later conversion to an open-source license. This is source-available, not OSI open source. Versions published before the switch were licensed under FSL-1.1-ALv2 and keep that license, including its Apache-2.0 grant two years after each publication. See LICENSE, NOTICE and LICENSES/ for third-party software and reference-data terms.

libpostal and rustpostal are MIT-licensed; deunicode is BSD-3-Clause. CLDR data is Unicode-3.0; GeoNames data is CC-BY-4.0. Model files are not bundled; their source-data licensing and attribution obligations are separate.

About

Rust address resolution with libpostal, Nominatim, ClickHouse, Connect and gRPC

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages