<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>Mailwoman Research Log</title>
        <link>https://mailwoman.ai/research</link>
        <description>Mailwoman Blog</description>
        <lastBuildDate>Tue, 21 Jul 2026 00:00:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <copyright>Copyright © 2026 Sister Software.</copyright>
        <item>
            <title><![CDATA[Turn on geocoding without turning it into a project]]></title>
            <link>https://mailwoman.ai/research/geocoding-in-your-stack</link>
            <guid>https://mailwoman.ai/research/geocoding-in-your-stack</guid>
            <pubDate>Tue, 21 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[You need to turn an address into a coordinate. That's the whole ask. The problem is that the two ways to get there both start as a project.]]></description>
            <content:encoded><![CDATA[<p>You need to turn an address into a coordinate. That's the whole ask. The problem is that the two ways to get there both start as a project.</p>
<p>Rent an API and the address is now someone else's business. Every lookup is a billable request and a row of your users' home addresses leaving your infrastructure, and the free tier caps out at one request a second, so a table of any size takes days. Self-host Nominatim instead and you've signed up for PostgreSQL, an <code>osm2pgsql</code> import measured in hours and tens of gigabytes, and a service you run on a box you can never embed inside your own app. Either way, the thing you wanted — <code>geocode(address)</code> — sits on the far side of a week of setup.</p>
<!-- -->
<p>Mailwoman is a parser and geocoder that runs from a SQLite file and a ~40&nbsp;MB model, entirely on your own machine (<a class="" href="https://mailwoman.ai/research/geocoding-that-never-phones-home">it makes no outbound network request</a>). None of that helps if wiring it in is its own week, though. So the work of the last stretch wasn't the model. It was making the thing install the way the rest of your dependencies do.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="your-existing-client-already-works">Your existing client already works<a href="https://mailwoman.ai/research/geocoding-in-your-stack#your-existing-client-already-works" class="hash-link" aria-label="Direct link to Your existing client already works" title="Direct link to Your existing client already works" translate="no">​</a></h2>
<p>Mailwoman ships endpoints that speak the wire formats your stack was already built against. <code>@mailwoman/nominatim</code> answers <code>/search</code> and <code>/reverse</code> and <code>/status</code> the way Nominatim does. <code>@mailwoman/photon</code> returns the GeoJSON that Photon clients expect for autocomplete. <code>@mailwoman/libpostal</code> parses and expands on the <code>/parse</code> and <code>/expand</code> routes. The client library you wrote against those services doesn't know it's talking to something else.</p>
<p>geopy is the clearest case. Its <code>Nominatim</code> class takes a <code>domain</code>, so pointing it at a local instance is two keyword arguments and no code change:</p>
<div class="ss-code language-python codeBlockContainer_mQmQ theme-code-block" style="--prism-color:#f8f8f2;--prism-background-color:#272823"><span class="ss-code-lang">python</span><div class="ss-code-content"><pre tabindex="0" class="prism-code language-python codeBlock_RMoD thin-scrollbar" style="color:#f8f8f2;background-color:#272823"><code class="codeBlockLines_AclH"><div class="token-line" style="color:#f8f8f2"><span class="token keyword" style="color:#66d9ef">from</span><span class="token plain"> geopy</span><span class="token punctuation" style="color:#f8f8f2">.</span><span class="token plain">geocoders </span><span class="token keyword" style="color:#66d9ef">import</span><span class="token plain"> Nominatim</span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain">geo </span><span class="token operator" style="color:#66d9ef">=</span><span class="token plain"> Nominatim</span><span class="token punctuation" style="color:#f8f8f2">(</span><span class="token plain">domain</span><span class="token operator" style="color:#66d9ef">=</span><span class="token string" style="color:#a6e22e">"localhost:8080"</span><span class="token punctuation" style="color:#f8f8f2">,</span><span class="token plain"> scheme</span><span class="token operator" style="color:#66d9ef">=</span><span class="token string" style="color:#a6e22e">"http"</span><span class="token punctuation" style="color:#f8f8f2">,</span><span class="token plain"> user_agent</span><span class="token operator" style="color:#66d9ef">=</span><span class="token string" style="color:#a6e22e">"my-app"</span><span class="token punctuation" style="color:#f8f8f2">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain">loc </span><span class="token operator" style="color:#66d9ef">=</span><span class="token plain"> geo</span><span class="token punctuation" style="color:#f8f8f2">.</span><span class="token plain">geocode</span><span class="token punctuation" style="color:#f8f8f2">(</span><span class="token string" style="color:#a6e22e">"1600 Pennsylvania Avenue NW, Washington, DC 20500"</span><span class="token punctuation" style="color:#f8f8f2">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain"></span><span class="token keyword" style="color:#66d9ef">print</span><span class="token punctuation" style="color:#f8f8f2">(</span><span class="token plain">loc</span><span class="token punctuation" style="color:#f8f8f2">.</span><span class="token plain">latitude</span><span class="token punctuation" style="color:#f8f8f2">,</span><span class="token plain"> loc</span><span class="token punctuation" style="color:#f8f8f2">.</span><span class="token plain">longitude</span><span class="token punctuation" style="color:#f8f8f2">)</span><span class="token plain">   </span><span class="token comment" style="color:#8292a2;font-style:italic"># 38.8977 -77.0365</span><br></div></code></pre><button type="button" class="ss-code-copy" aria-label="Copy code to clipboard"><span aria-live="polite">Copy</span></button></div></div>
<p>Same call you already had. It now resolves against a rooftop-level result on your own box, The API returns an OpenCage-style <code>annotations</code> block — timezone, coordinate formats, the things upstream Nominatim leaves empty — hanging off <code>loc.raw</code>.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="one-docker-run-for-the-server">One <code>docker run</code> for the server<a href="https://mailwoman.ai/research/geocoding-in-your-stack#one-docker-run-for-the-server" class="hash-link" aria-label="Direct link to one-docker-run-for-the-server" title="Direct link to one-docker-run-for-the-server" translate="no">​</a></h2>
<p>The container installs the published packages and bakes the model weights in, so a fresh pull parses addresses with no data at all. The container needs a gazetteer at runtime, so it mounts one at <code>/data</code>:</p>
<div class="ss-code ss-code--shell language-bash codeBlockContainer_mQmQ theme-code-block" style="--prism-color:#f8f8f2;--prism-background-color:#272823"><span class="ss-code-lang">bash</span><div class="ss-code-content"><pre tabindex="0" class="prism-code language-bash codeBlock_RMoD thin-scrollbar" style="color:#f8f8f2;background-color:#272823"><code class="codeBlockLines_AclH"><div class="token-line" style="color:#f8f8f2"><span class="token plain">docker run --rm -p 8080:8080 \</span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain">  -v /path/to/mailwoman-data:/data:ro \</span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain">  ghcr.io/sister-software/mailwoman:latest \</span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain">  node node_modules/@mailwoman/nominatim/out/cli.js serve</span><br></div></code></pre><button type="button" class="ss-code-copy" aria-label="Copy code to clipboard"><span aria-live="polite">Copy</span></button></div></div>
<p>You have an endpoint. Curl it, point geopy at it, put it behind your load balancer. The image also runs the native <code>/v1</code> API and the Photon and libpostal drop-ins on their own commands, so one artifact covers the formats your services speak.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="in-process-when-a-round-trip-is-silly">In-process, when a round trip is silly<a href="https://mailwoman.ai/research/geocoding-in-your-stack#in-process-when-a-round-trip-is-silly" class="hash-link" aria-label="Direct link to In-process, when a round trip is silly" title="Direct link to In-process, when a round trip is silly" translate="no">​</a></h2>
<p>A Node service that needs a coordinate shouldn't have to talk to itself over HTTP. <code>@mailwoman/fastify</code> registers the routes and a <code>fastify.mailwoman</code> decorator over the local pipeline, built once at startup:</p>
<div class="ss-code language-ts codeBlockContainer_mQmQ theme-code-block" style="--prism-color:#f8f8f2;--prism-background-color:#272823"><span class="ss-code-lang">ts</span><div class="ss-code-content"><pre tabindex="0" class="prism-code language-ts codeBlock_RMoD thin-scrollbar" style="color:#f8f8f2;background-color:#272823"><code class="codeBlockLines_AclH"><div class="token-line" style="color:#f8f8f2"><span class="token keyword" style="color:#66d9ef">import</span><span class="token plain"> Fastify </span><span class="token keyword" style="color:#66d9ef">from</span><span class="token plain"> </span><span class="token string" style="color:#a6e22e">"fastify"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain"></span><span class="token keyword" style="color:#66d9ef">import</span><span class="token plain"> mailwoman </span><span class="token keyword" style="color:#66d9ef">from</span><span class="token plain"> </span><span class="token string" style="color:#a6e22e">"@mailwoman/fastify"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain"></span><span class="token keyword" style="color:#66d9ef">const</span><span class="token plain"> app </span><span class="token operator" style="color:#66d9ef">=</span><span class="token plain"> </span><span class="token function" style="color:#e6db74">Fastify</span><span class="token punctuation" style="color:#f8f8f2">(</span><span class="token punctuation" style="color:#f8f8f2">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain"></span><span class="token keyword" style="color:#66d9ef">await</span><span class="token plain"> app</span><span class="token punctuation" style="color:#f8f8f2">.</span><span class="token function" style="color:#e6db74">register</span><span class="token punctuation" style="color:#f8f8f2">(</span><span class="token plain">mailwoman</span><span class="token punctuation" style="color:#f8f8f2">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#f8f8f2">{</span><span class="token plain"> resolveDatabasePath</span><span class="token operator" style="color:#66d9ef">:</span><span class="token plain"> </span><span class="token string" style="color:#a6e22e">"/data/wof/candidate.db"</span><span class="token plain"> </span><span class="token punctuation" style="color:#f8f8f2">}</span><span class="token punctuation" style="color:#f8f8f2">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain"></span><span class="token keyword" style="color:#66d9ef">const</span><span class="token plain"> </span><span class="token punctuation" style="color:#f8f8f2">{</span><span class="token plain"> lat</span><span class="token punctuation" style="color:#f8f8f2">,</span><span class="token plain"> lon </span><span class="token punctuation" style="color:#f8f8f2">}</span><span class="token plain"> </span><span class="token operator" style="color:#66d9ef">=</span><span class="token plain"> </span><span class="token keyword" style="color:#66d9ef">await</span><span class="token plain"> app</span><span class="token punctuation" style="color:#f8f8f2">.</span><span class="token plain">mailwoman</span><span class="token punctuation" style="color:#f8f8f2">.</span><span class="token function" style="color:#e6db74">geocode</span><span class="token punctuation" style="color:#f8f8f2">(</span><span class="token string" style="color:#a6e22e">"350 5th Ave, New York"</span><span class="token punctuation" style="color:#f8f8f2">)</span><br></div></code></pre><button type="button" class="ss-code-copy" aria-label="Copy code to clipboard"><span aria-live="polite">Copy</span></button></div></div>
<p>For the frontend, <code>@mailwoman/react</code> is the parse, geocode, and POI components that drive our own docs demos, split into small pieces and headless hooks so you can take the <code>usePOISearch</code> logic without our markup. Python and Rust callers have generated clients on PyPI and crates.io (<code>mailwoman-client</code>). And for agents, <code>@mailwoman/mcp</code> exposes parsing, geocoding, POI search, and spatial export as MCP tools over stdio, so a model can look an address up as a tool call instead of guessing at coordinates.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="what-it-costs-you">What it costs you<a href="https://mailwoman.ai/research/geocoding-in-your-stack#what-it-costs-you" class="hash-link" aria-label="Direct link to What it costs you" title="Direct link to What it costs you" translate="no">​</a></h2>
<p>You bring the gazetteer. The model is in the image, but geocoding needs the admin database. You download it or build it. The worldwide candidate file is about 1.4&nbsp;GB, and it's read-only, so it mounts <code>:ro</code> and you never think about it again. Until it's in place, the server parses but won't geocode. That is a degrade with a clear message rather than a crash. If you're not sure what's present, <code>mailwoman doctor</code> checks the weights, the data root, the gazetteer, and the runtime and tells you the one command to fix each gap.</p>
<p>The other cost is the shape. This is SQLite rather than a cluster, so one instance is one node. That's the point on a single box or inside an app, and it means you scale by running more copies behind a balancer rather than by tuning a shared database. If your plan was a managed Postgres fleet, this isn't that, on purpose.</p>
<p>And the license is AGPL, with a commercial option. If you're embedding it in something you ship closed, that's the conversation to have first.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="where-it-stops">Where it stops<a href="https://mailwoman.ai/research/geocoding-in-your-stack#where-it-stops" class="hash-link" aria-label="Direct link to Where it stops" title="Direct link to Where it stops" translate="no">​</a></h2>
<p>None of this changes what the geocoder does — it's the same parser and the same resolver from a month of eval posts. What changed is the distance between reading this and having an endpoint. That step is now a <code>docker run</code> or an <code>npm install</code> rather than a migration. The address still turns into a coordinate. It does it inside the stack you already have.</p>]]></content:encoded>
            <category>Geocoding</category>
            <category>Infrastructure</category>
            <category>Project motivation</category>
        </item>
        <item>
            <title><![CDATA[666,000 addresses and not one country]]></title>
            <link>https://mailwoman.ai/research/666000-addresses-and-not-one-country</link>
            <guid>https://mailwoman.ai/research/666000-addresses-and-not-one-country</guid>
            <pubDate>Sat, 18 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[I taught the address parser three new countries overnight. Two of them landed. The third failed a regression check I trusted, and the reason was concerning: the new addresses never included their own country, and the model was learning to stop labeling countries at all.]]></description>
            <content:encoded><![CDATA[<p>A month ago I wrote about adding Canada to the geocoder and seeing it put Toronto in Ohio. In that case, 843,000 correct postcodes never affected the result, because the layer I had built was not the layer that shipped. This post describes a related problem. Last night I added three new countries to the <em>parser</em>. Two of them worked. The third failed a regression check I trusted, and the investigation showed that the addresses were teaching the model something I had not intended.</p>
<!-- -->
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-easy-part">The easy part<a href="https://mailwoman.ai/research/666000-addresses-and-not-one-country#the-easy-part" class="hash-link" aria-label="Direct link to The easy part" title="Direct link to The easy part" translate="no">​</a></h2>
<p>The parser reads a raw string such as <code>2835 Woodhaven Rd, Shirley, V9Z1G6</code> and labels every piece: house number, street, city, and postcode. It is a neural sequence model, and it learns a new country from a few hundred thousand real examples. We had a new source for those examples: Overture's open addresses theme, with millions of rows per country, a permissive license, and no API key. Canada returned sixteen million addresses, Mexico thirty million, and Brazil ninety million.</p>
<p>Canada and Mexico trained without problems. Mexico showed the main improvement: the model's accuracy on Mexican cities went from mediocre to good. It now reads <code>Calle Zeverino León 749, 28809 Manzanillo</code> and labels <em>Manzanillo</em> correctly instead of guessing. The model already handled Canada reasonably well, because Canadian addresses resemble American ones, so adding Canada neither cost nor gained anything measurable. Both countries went into one training run, and the whole battery passed. I then prepared Brazil as the third country.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-guard-that-said-no">The guard that said no<a href="https://mailwoman.ai/research/666000-addresses-and-not-one-country#the-guard-that-said-no" class="hash-link" aria-label="Direct link to The guard that said no" title="Direct link to The guard that said no" translate="no">​</a></h2>
<p>Every model change runs a check before it can reach production. Part of the check confirms that the new data worked. The required part checks whether anything else got worse. We keep a frozen set of graded addresses and require that no category regress by more than two points. The check is simple, and it is what lets us move quickly without shipping unnoticed regressions.</p>
<p>Brazil built and trained without errors. The check then reported one failure. Country recall, the model's ability to extract the country from an address that includes one, had dropped more than five points. Every other category (cities, streets, postcodes, and house numbers) stayed flat. Only country dropped, and by enough to fail.</p>
<p>The earlier run explained the pattern. The run with only Canada and Mexico had lowered country recall by about a point and a half. That drop was under the threshold and easy to dismiss as noise. Brazil enlarged the <em>existing</em> problem. The two runs showed a lower country-label rate after more addresses were added.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="what-the-addresses-forgot-to-say">What the addresses forgot to say<a href="https://mailwoman.ai/research/666000-addresses-and-not-one-country#what-the-addresses-forgot-to-say" class="hash-link" aria-label="Direct link to What the addresses forgot to say" title="Direct link to What the addresses forgot to say" translate="no">​</a></h2>
<p>I then inspected the training rows themselves instead of the model. None of the 666,000 new Canadian, Mexican, and Brazilian rows contained a country.</p>
<p>That is normal for the source. An Overture address is <code>street, city, postcode</code>. The country is implied by the file it came from, in the same way that a domestic letter omits the country from the envelope. Each row is correct on its own. A model, however, learns from the whole set. I had given it two-thirds of a million addresses, and none of them included a country. Trained on that data at the weight I had given it, the model learned that <em>addresses do not include their country</em>, in addition to learning three new countries. When the graded set gives it <code>1600 Pennsylvania Ave NW, Washington, DC, USA</code>, which does include a country, the model has learned to skip the <code>USA</code> token.</p>
<p>I had checked that the new rows had the tags I wanted: street, city, and postcode, all present and correct. I never checked for the tag that was missing, and the missing tag caused the regression.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-fix-is-old-and-we-already-own-it">The fix is old, and we already own it<a href="https://mailwoman.ai/research/666000-addresses-and-not-one-country#the-fix-is-old-and-we-already-own-it" class="hash-link" aria-label="Direct link to The fix is old, and we already own it" title="Direct link to The fix is old, and we already own it" translate="no">​</a></h2>
<p>We built the fix for this a year ago and did not apply it to the new pipeline. Our older locale recipe, the one behind the European countries, renders addresses in their native form and also renders a share of them in an <em>international</em> form, the kind written on a parcel crossing a border: <code>12 Rue de Rivoli, Paris, France</code>. That form includes the country deliberately, and it is why adding France never taught the model to drop France. The new Overture pipeline skipped that step and used the addresses exactly as given, without countries.</p>
<p>Brazil therefore needs only a small change, and the design stays as it is. The change is to render a fraction of the new rows with the country included, so the training set stops teaching the model to omit it. Canada and Mexico ship as they are, because that run passed. Brazil waits for the next build, and the fix is smaller than it seemed at 4 a.m.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-lesson-in-a-sentence">The lesson, in a sentence<a href="https://mailwoman.ai/research/666000-addresses-and-not-one-country#the-lesson-in-a-sentence" class="hash-link" aria-label="Direct link to The lesson, in a sentence" title="Direct link to The lesson, in a sentence" translate="no">​</a></h2>
<p>June's lesson was to <em>grade the coordinate the user sees rather than the layer you happened to build.</em> This one is related: <strong>a model also learns from what your training data consistently omits.</strong> Every field you consistently leave out is a field you are teaching the model to leave out. Coverage includes what you put in the corpus and also what you leave out. The model can learn an omission as a rule when the omission repeats across a million rows.</p>
<p>A simple two-point regression check caught this before any user saw it. The same check has now caught two different problems of this kind.</p>]]></content:encoded>
            <category>Corpus pipeline</category>
            <category>Model training</category>
            <category>Multi-locale</category>
            <category>Overture Maps</category>
            <category>Testing</category>
            <category>Methodology</category>
        </item>
        <item>
            <title><![CDATA[We asked our address parser what it couldn't read]]></title>
            <link>https://mailwoman.ai/research/2026/07/01/what-our-parser-cant-read</link>
            <guid>https://mailwoman.ai/research/2026/07/01/what-our-parser-cant-read</guid>
            <pubDate>Wed, 01 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Most sequence labelers throw away the parts of an address they don't understand, and the output format is built to hide it. We made ours hand every dropped character back, then aggregated them into a ranked list. The top of the list turned out to be the Polish alphabet.]]></description>
            <content:encoded><![CDATA[<p>Feed an address into a sequence labeler and part of it goes missing. The model tags the tokens it recognizes — house number, street, city — and the characters it doesn't understand fall on the floor. <code>decodeAsJSON</code> hands you back a tidy object with every field in its place, and no field in that object tells you the modelshrugged at a fifth of the input.</p>
<p>So you ship that parser. It looks great on the addresses you tested it against. And you have no idea what it's dropping in production, because the output format was built to hide exactly that.</p>
<!-- -->
<p>We got tired of not knowing. So we made the decoder keep a promise: every byte of the input lands in exactly one typed span, including the bytes we can't classify. The runs the model leaves untagged stop being absence and become a thing you can hold — an <code>unknown</code> span, with a start, an end, and the literal text the model couldn't place. Concatenate all the spans back together and you get the original string, character for character. No byte gets lost, because there's nowhere left for it to go.</p>
<p>That's a small change to a decoder. It turns into a diagnostic you can point at production addresses.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-shopping-list">The shopping list<a href="https://mailwoman.ai/research/2026/07/01/what-our-parser-cant-read#the-shopping-list" class="hash-link" aria-label="Direct link to The shopping list" title="Direct link to The shopping list" translate="no">​</a></h2>
<p>Once the gaps are typed, you can count them. We ran the parser over a few thousand real addresses and folded every <code>unknown</code> span into a frequency table: a ranked list of what the model systematically fails to read. We started calling it the shopping list, because that's what it is. The next retrain, in priority order, written by the model's own failures instead of our hunches.</p>
<p>The first thing the list did was catch us lying to ourselves. Ninety-eight percent of inputs had an unknown span. Ninety-eight! That number is worthless, and it took about four minutes to see why: the gaps were almost all whitespace and commas, the spaces between tokens that no component owns. A metric that's mostly punctuation will tell you the sky is falling on every single address. Once we separated the trivial delimiter gaps from gaps holding an actual letter or digit, the direct figure dropped to nine percent. Nine percent of addresses contain a run of real content the model can't place. That one you can act on.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="what-it-cant-read-is-polish">What it can't read is Polish<a href="https://mailwoman.ai/research/2026/07/01/what-our-parser-cant-read#what-it-cant-read-is-polish" class="hash-link" aria-label="Direct link to What it can't read is Polish" title="Direct link to What it can't read is Polish" translate="no">​</a></h2>
<p>Then we aimed the same tool at OpenAddresses data from nine countries, and the shopping list stopped being abstract.</p>
<p>The United States and Australia came back spotless: zero content gaps. France, ten percent. Italy, two. And then Czechia at eighty-four percent and Poland at seventy-seven, towering over everything else on the board.</p>
<p>The top entries said why in the plainest possible terms: <code>ł</code>, <code>á</code>, <code>ó</code>, <code>í</code>, <code>ě</code>, <code>ś</code>, <code>ę</code>, <code>č</code>. The whole Latin-Extended block. Our tokenizer isolates a Slavic diacritic into its own little subword, the model was never shown enough of them to learn what they are, so it files them under background noise. <code>Grudziądz</code> comes apart at the <code>ą</code>: the model labels <code>Grudzi</code>, drops the accent, and picks the city back up at <code>dz</code>, as if the letter in the middle were a coffee stain. <code>Bohaterów</code> splits at the <code>ó</code> and takes the neighboring house number down with it.</p>
<p>We had a comfortable theory that this was a decoder bug, a character-offset mismatch around multi-byte characters, the kind of thing you fix in an afternoon without touching a GPU. We checked before we wrote it down. It wasn't the decoder. The offsets were fine. The model only doesn't know these letters, and there is no clever preprocessing that fixes not-knowing. It's a retrain. The difference is that now we know precisely which characters to weight and which locales to leave unchanged, because Australia and the US don't need a thing.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="honesty-you-can-query">Honesty you can query<a href="https://mailwoman.ai/research/2026/07/01/what-our-parser-cant-read#honesty-you-can-query" class="hash-link" aria-label="Direct link to Honesty you can query" title="Direct link to Honesty you can query" translate="no">​</a></h2>
<p>A model that guesses without a word is a liability, because you learn what it can't do from a customer. A model that hands you a typed, countable record of its own blind spots is something you can plan around. We didn't make the parser smarter this week. We made it direct about what it doesn't know, and that honesty turned out to be a feature you can run a <code>GROUP BY</code> over.</p>
<p>The shopping list is checked in, and it runs before and after every retrain now. Next time you build something that classifies text, make it account for the text it couldn't classify. You'll be surprised how loudly it starts telling you where to look.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="update--july-2">Update — July 2<a href="https://mailwoman.ai/research/2026/07/01/what-our-parser-cant-read#update--july-2" class="hash-link" aria-label="Direct link to Update — July 2" title="Direct link to Update — July 2" translate="no">​</a></h2>
<p>We wrote "it's a retrain" and then we tested it, because writing a thing down is not the same as it being true. The retrain held English and made Czech worse. What the ranked list had found was one layer lower: the tokenizer's vocabulary stored every one of those characters as its own lonely subword and not a single piece containing them, and a unigram model cannot emit a subword that isn't in its table. No volume of training data teaches a model to say a word its alphabet can't spell.</p>
<p>So the fix wasn't a retrain. We trained a small SentencePiece model on Czech and Polish addresses, spliced its 10,582 diacritic-containing pieces into the vocabulary, and initialized each new embedding from the mean of the pieces it used to shatter into. Zero GPU hours. Wrong-city rates fell from 22.4% to 14.8% in Czechia and from 27.9% to 7.9% in Poland, and Slovak and Slovenian improved with no further work because they share the codepoints. It shipped the next day.</p>
<p>The shopping list went from measurement to a shipped fix in under twenty-four hours. The fix was cheap because the blind spots were countable. <code>Grudziądz</code> is whole now.</p>]]></content:encoded>
            <category>Neural classifier</category>
            <category>Corpus pipeline</category>
            <category>Evaluation</category>
            <category>International</category>
        </item>
        <item>
            <title><![CDATA[The model knew it was Austria. The resolver sent it to West Virginia.]]></title>
            <link>https://mailwoman.ai/research/2026/06/30/the-model-knew-it-was-austria</link>
            <guid>https://mailwoman.ai/research/2026/06/30/the-model-knew-it-was-austria</guid>
            <pubDate>Tue, 30 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Type 'Vienna, Austria' into our geocoder and it confidently returned a town of ten thousand people in Appalachia. The obvious fix was a retrain. We ran the diagnostic first, and the obvious fix turned out to be the wrong one.]]></description>
            <content:encoded><![CDATA[<p>Type <code>Vienna, Austria</code> into our geocoder and, until this week, it answered with a confident set of coordinates: 39.32, −81.54. That's Vienna, West Virginia — population about ten thousand, a few miles up the Ohio River from Parkersburg. The capital of Austria is 7,500 kilometers and one ocean away, and the geocoder had no doubt whatsoever.</p>
<!-- -->
<p>It does this with Sydney too (a hamlet in Ohio), and Zurich (Kansas), and Toronto (also Ohio, as it happens). The pattern is always the same: you name a famous foreign city, you name the country it's in, and the geocoder hands you back its smallest American namesake like it's doing you a favor.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-fix-that-felt-like-progress">The fix that felt like progress<a href="https://mailwoman.ai/research/2026/06/30/the-model-knew-it-was-austria#the-fix-that-felt-like-progress" class="hash-link" aria-label="Direct link to The fix that felt like progress" title="Direct link to The fix that felt like progress" translate="no">​</a></h2>
<p>When you find a bug like this, the first idea is almost always the expensive one. Ours was: the country router doesn't know enough countries. Teach it that "Austria" means Austria — feed it more examples, retrain the model, ship a new set of weights. It's a satisfying plan. It feels like progress, because training a model is the kind of work that looks like work.</p>
<p>We had the GPU budget queued and the issue written up as a model gap. And then, because we've been burned before by fixing the wrong layer, we did the cheap thing first: we asked the model what it saw.</p>
<p>Here is <code>Vienna, Austria</code> straight out of the parser:</p>
<div class="ss-code language-text codeBlockContainer_mQmQ theme-code-block" style="--prism-color:#f8f8f2;--prism-background-color:#272823"><span class="ss-code-lang">text</span><div class="ss-code-content"><pre tabindex="0" class="prism-code language-text codeBlock_RMoD thin-scrollbar" style="color:#f8f8f2;background-color:#272823"><code class="codeBlockLines_AclH"><div class="token-line" style="color:#f8f8f2"><span class="token plain">Vienna   → locality</span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain">Austria  → country</span><br></div></code></pre><button type="button" class="ss-code-copy" aria-label="Copy code to clipboard"><span aria-live="polite">Copy</span></button></div></div>
<p>The model knew. It tagged Austria as a country, directly, every time — Australia, Switzerland, Canada, all of them. There was no model gap. The thing we were about to spend a retrain on was already working perfectly. The bug lived one layer down, in the part of the system that takes a correct parse and turns it into a place on Earth.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="forty-five-viennas-and-a-popularity-contest">Forty-five Viennas and a popularity contest<a href="https://mailwoman.ai/research/2026/06/30/the-model-knew-it-was-austria#forty-five-viennas-and-a-popularity-contest" class="hash-link" aria-label="Direct link to Forty-five Viennas and a popularity contest" title="Direct link to Forty-five Viennas and a popularity contest" translate="no">​</a></h2>
<p>The resolver's job is to take the word "Vienna" and decide which Vienna. Our gazetteer knows about forty-five of them in the United States, plus the real one in Austria, plus a few others. Faced with that pile, the resolver did the reasonable-sounding thing: it picked the most prominent match by population and name — and then it threw the <code>country</code> token away. It never asked the one question the address had already answered for it.</p>
<p>So <code>Vienna</code> resolved on its own merits, the populous American candidates outranked the Austrian one in the bag, and <code>Austria</code> sat there in the parse, ignored, like an unread return address. The model had said <em>which country</em> in plain text, and the resolver wasn't listening.</p>
<p>The direct version of the bug: the parse and the resolve each made a locally sensible decision, and no stage checked that they agreed.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-fix-we-didnt-make">The fix we didn't make<a href="https://mailwoman.ai/research/2026/06/30/the-model-knew-it-was-austria#the-fix-we-didnt-make" class="hash-link" aria-label="Direct link to The fix we didn't make" title="Direct link to The fix we didn't make" translate="no">​</a></h2>
<p>There's a tempting shortcut here, and we've watched it sink geocoders before. You could keep a list. "If the address contains Austria, force the country to AT." It would work for Vienna, and Sydney, and Zurich. It would also be the first paving stone on the road to a thousand-entry exception table that someone maintains forever and no user trusts — the same road that turned older rules-based geocoders into archaeology. We don't pin, and we don't keep lists of special cases the model is supposed to defer to.</p>
<p>The fix instead asks the parse and the resolve to be consistent with <em>each other</em>. When the address includes an explicit country and the locality it resolved to lives somewhere else, re-pick the locality to the one that sits inside the country the address states. The country code comes from the model's own <code>country</code> emission, normalized through an ISO table we already had. The gazetteer's own <code>country</code> column does the rest. No list. No prior. It generalizes to every country at once, because it isn't about any particular country — it's about the address agreeing with itself.</p>
<p>The fix has a guard rail, because this kind of fix typically goes wrong at that point. It only fires when the country is the most specific geographic evidence in the address. Write <code>Springfield, IL, USA</code> and the state is right there doing the disambiguating, so the new pass stays out of the way and Springfield stays in Illinois — not the largest Springfield in America. The fix warrants its keep precisely by knowing when not to act.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="what-it-bought-and-what-it-cost">What it bought, and what it cost<a href="https://mailwoman.ai/research/2026/06/30/the-model-knew-it-was-austria#what-it-bought-and-what-it-cost" class="hash-link" aria-label="Direct link to What it bought, and what it cost" title="Direct link to What it bought, and what it cost" translate="no">​</a></h2>
<p>We measure these things against coordinates rather than labels, because a name-match metric would have told us this bug didn't exist. The direct yardstick is a bare <code>City, Country</code> query — the exact thing a user types into the box — scored by how close the answer lands to the real place.</p>
<table><thead><tr><th></th><th style="text-align:right">before</th><th style="text-align:right">after</th></tr></thead><tbody><tr><td><code>City, Country</code> resolves correctly</td><td style="text-align:right">54.2%</td><td style="text-align:right"><strong>77.9%</strong></td></tr><tr><td>countries the country-name now rescues</td><td style="text-align:right">—</td><td style="text-align:right"><strong>45 of 57</strong></td></tr></tbody></table>
<p>Forty-five countries that used to need a hint and now don't, a twenty-four-point jump on the query shape people use, and the cost on the GPU meter was zero. The thing we'd budgeted a retrain for was a hundred-odd lines in the resolver.</p>
<p>The fix does have a direct cost: it can only re-pick to a place the gazetteer has. Where the gazetteer lacks a city, the country in the address can't summon a place the gazetteer lacks, and the fix correctly declines to guess. That's a coverage problem, and it's a different change for a different day.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-lesson">The lesson<a href="https://mailwoman.ai/research/2026/06/30/the-model-knew-it-was-austria#the-lesson" class="hash-link" aria-label="Direct link to The lesson" title="Direct link to The lesson" translate="no">​</a></h2>
<p>We almost retrained a model to fix a bug the model didn't have. The diagnostic that saved us was thirty seconds of parsing seven addresses and reading the output — cheaper than the issue we'd written, never mind the GPU hours behind it.</p>
<p>The reflex is to train. The discipline is to look first. The model knew it was Austria the whole time. We had to teach the rest of the system to listen.</p>
<p>(One more for the honesty file: the test that proves Sydney now lands in Australia failed the first time I ran it — by 7,532 kilometers. The fix was fine. I'd dropped the minus sign on Sydney's latitude and parked it in the northern hemisphere. The check caught my typo before it caught a real regression. That is exactly the order you want.)</p>]]></content:encoded>
            <category>Resolver / WOF</category>
            <category>Evaluation</category>
        </item>
        <item>
            <title><![CDATA[We almost retrained a model to fix a stale symlink]]></title>
            <link>https://mailwoman.ai/research/2026/06/28/stale-symlink</link>
            <guid>https://mailwoman.ai/research/2026/06/28/stale-symlink</guid>
            <pubDate>Sun, 28 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Mailwoman couldn't find Tirana, or Yerevan, or 145 other capitals — not a wrong coordinate, silence. We were going to fix the parser. A ten-minute probe killed that plan, and a stale symlink nearly talked us into a GPU retrain to fix a model that was already fine. Two evals, two silent confounds, and the one dull habit that caught both.]]></description>
            <content:encoded><![CDATA[<p>Last night the plan was simple: mailwoman was missing a pile of non-US coverage, and we were going to fix the parser to close the gap. By morning we'd shipped something better than planned, killed the original idea, and caught ourselves about to spend a GPU budget on a bug that lived in a symlink. Here's how a night of "fix the model" turned into "stop trusting your evals."</p>
<!-- -->
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-geocoder-couldnt-find-tirana">The geocoder couldn't find Tirana<a href="https://mailwoman.ai/research/2026/06/28/stale-symlink#the-geocoder-couldnt-find-tirana" class="hash-link" aria-label="Direct link to The geocoder couldn't find Tirana" title="Direct link to The geocoder couldn't find Tirana" translate="no">​</a></h2>
<p>Start with the symptom. Ask mailwoman's drop-in API for <code>Tirana, Albania</code> and you got no result. Same for Yerevan, Kabul, Ulaanbaatar, Sarajevo — 147 countries where the candidate gazetteer, the table the demo and the drop-ins query, held exactly zero rows. You didn't get a wrong coordinate, you got silence, as if the country didn't exist.</p>
<p>That gap is easy to miss, because the cities that do resolve — Berlin, Paris, Tokyo — resolve beautifully, and those are the ones you check by hand. The hole was in the long tail of countries that no one on the team types into a demo at 2am.</p>
<p>If you've shipped a data pipeline you know how this happens. A from-scratch rebuild enriched every existing place with alternate-language names, so Berlin now answers to Berlino, Берлин, 柏林, all 134 of its surface forms. Wonderful. But enriching the places you have adds no coverage for the places you don't, and the gap-fill that was supposed to add the missing countries lived in a side index the candidate table never folded in. Every piece worked. The join between them was empty, and no component failed loudly enough to notice.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-plan-was-wrong-and-a-ten-minute-probe-said-so">The plan was wrong, and a ten-minute probe said so<a href="https://mailwoman.ai/research/2026/06/28/stale-symlink#the-plan-was-wrong-and-a-ten-minute-probe-said-so" class="hash-link" aria-label="Direct link to The plan was wrong, and a ten-minute probe said so" title="Direct link to The plan was wrong, and a ten-minute probe said so" translate="no">​</a></h2>
<p>The plan blamed exonyms — the Warsaw-versus-Warszawa problem, where the address uses one name and the gazetteer holds another. Reasonable theory, and an expensive fix: a model retrain to learn the aliases.</p>
<p>So before betting the night on it, we ran the cheap version first. For the top cities of every failing country, is the local name already in the table? If it is, exonyms are the problem. If it isn't, it's coverage. The answer came back in ten minutes: exonym reliance was about one percent. The alt-names were already there. The cities themselves were missing.</p>
<p>That killed the expensive theory and pointed at a boring data fix: pull the real GeoNames town-level dumps for all 147 countries and rebuild the table. It worked the way data fixes do — completely. Before, zero of twenty-five sampled capitals resolved. After, twenty-five of twenty-five, every one inside six kilometers, Tirana to the meter. The countries that already worked came through byte-for-byte identical, because every new row sat in a country that previously had none. No regression to argue about.</p>
<p>That's the result, and we staged it to ship. It's the part you'd put in the changelog. I learned more from what came next.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="then-the-eval-lied-to-me">Then the eval lied to me<a href="https://mailwoman.ai/research/2026/06/28/stale-symlink#then-the-eval-lied-to-me" class="hash-link" aria-label="Direct link to Then the eval lied to me" title="Direct link to Then the eval lied to me" translate="no">​</a></h2>
<p>With coverage handled, we asked a different question: when mailwoman does parse a foreign address, does it land in the right place? We ran the assembled-coordinate eval on Portugal, Poland, and Australia — real addresses, scored on distance to truth.</p>
<p>The numbers were ugly. Portugal's median error came back at 47 kilometers. Poland 116. Australia 798. The parser was reading the street name as the city and resolving to a town with that name three regions over. Feed it a perfect parse and the error dropped under a kilometer. Clean story: the parser is the bottleneck, this warrants a GPU retrain, write it up as a GO.</p>
<p>I almost did. Then I checked which model the eval had loaded.</p>
<p>It was a six-week-old build. The dev symlink that points at "the shipped model" had drifted to an old one — a test re-creates that symlink as a side effect, and it had been pinning the wrong file for weeks. Every eval that loaded the model by its default path was grading something we retired in May.</p>
<p>Point the eval at what we ship and Portugal's 47 kilometers becomes 0.8. Poland 2.3. Australia 1.2. The disaster evaporated. The shipped parser was fine the whole time. The only broken thing was the ruler I measured it with. The retrain I was about to recommend would have fixed a problem that didn't exist — and the multilocale parser that does fix this, for the cases where it's real, already sits in a validated checkpoint waiting on a promotion that never came. We didn't need a GPU. We needed to read a symlink.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="what-went-wrong-both-times">What went wrong, both times<a href="https://mailwoman.ai/research/2026/06/28/stale-symlink#what-went-wrong-both-times" class="hash-link" aria-label="Direct link to What went wrong, both times" title="Direct link to What went wrong, both times" translate="no">​</a></h2>
<p>Twice in one night the thing being measured wasn't the thing we thought. The plan's diagnostic ran without the candidate database loaded, so it blamed coverage on exonyms — the same missing-database confound that <a class="" href="https://mailwoman.ai/research/2026/06/26/warsaw-indiana">put Warsaw in Indiana</a> and faked a whole scoreboard a few weeks back. My parser eval ran against a stale model, so it blamed the shipped parser for a retired one's mistakes. Same failure wearing two costumes: the harness reported its numbers and stayed quiet about its assumptions.</p>
<p>The only thing that caught either one was the same dull habit — before you trust an aggregate, check what produced it. Dump the rows. Read the config. Print the path the symlink resolves to. It is never the interesting work, and it is most of the job. A model that scores 47 kilometers and a model that scores 0.8 can be the same model graded two ways, and the gap between "ship a fix" and "burn a week on no real problem" is whether you looked.</p>
<p>We fixed the symlink, bumped its default to the shipped weights, and left a louder note, since the script's own comments admit this drift cost an eval shift once before. It will drift again the first time someone forgets to bump it. That's the lesson, and the unsatisfying one: the bug is rarely in the model. It's in what you handed the eval, and the eval will not volunteer it.</p>]]></content:encoded>
            <category>Geocoding</category>
            <category>Evaluation</category>
            <category>Retrospective</category>
            <category>Geocoder hubris</category>
        </item>
        <item>
            <title><![CDATA[We made Mailwoman speak Nominatim, and it put Warsaw in Indiana]]></title>
            <link>https://mailwoman.ai/research/2026/06/26/warsaw-indiana</link>
            <guid>https://mailwoman.ai/research/2026/06/26/warsaw-indiana</guid>
            <pubDate>Fri, 26 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[You can point a Nominatim client at Mailwoman now and get a geocoder from a SQLite file, without PostgreSQL or a planet import. We did, and the US worked beautifully while the rest of the world resolved to its American namesake. The bug was one line: we'd told it the answer was always America. This post covers the line, the four cities that still won't move once you delete it, and the ranking database we forgot to switch on — which faked the entire scoreboard until a contradiction gave it away.]]></description>
            <content:encoded><![CDATA[<p>You can point a Nominatim client at Mailwoman now. It serves the same endpoints (<code>/search</code>, <code>/reverse</code>, <code>/status</code>) with the same JSON shape, so your existing code does not change. The backend is different. It needs no PostgreSQL, no <code>osm2pgsql</code> import that takes hours and tens of gigabytes, and no separate server, so it can ship inside an app. It answers Nominatim's queries from a SQLite file.</p>
<!-- -->
<div class="ss-code language-python codeBlockContainer_mQmQ theme-code-block" style="--prism-color:#f8f8f2;--prism-background-color:#272823"><span class="ss-code-lang">python</span><div class="ss-code-content"><pre tabindex="0" class="prism-code language-python codeBlock_RMoD thin-scrollbar" style="color:#f8f8f2;background-color:#272823"><code class="codeBlockLines_AclH"><div class="token-line" style="color:#f8f8f2"><span class="token keyword" style="color:#66d9ef">from</span><span class="token plain"> geopy</span><span class="token punctuation" style="color:#f8f8f2">.</span><span class="token plain">geocoders </span><span class="token keyword" style="color:#66d9ef">import</span><span class="token plain"> Nominatim</span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain">geo </span><span class="token operator" style="color:#66d9ef">=</span><span class="token plain"> Nominatim</span><span class="token punctuation" style="color:#f8f8f2">(</span><span class="token plain">domain</span><span class="token operator" style="color:#66d9ef">=</span><span class="token string" style="color:#a6e22e">"localhost:8080"</span><span class="token punctuation" style="color:#f8f8f2">,</span><span class="token plain"> scheme</span><span class="token operator" style="color:#66d9ef">=</span><span class="token string" style="color:#a6e22e">"http"</span><span class="token punctuation" style="color:#f8f8f2">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain">geo</span><span class="token punctuation" style="color:#f8f8f2">.</span><span class="token plain">geocode</span><span class="token punctuation" style="color:#f8f8f2">(</span><span class="token string" style="color:#a6e22e">"1600 Pennsylvania Ave NW, Washington DC"</span><span class="token punctuation" style="color:#f8f8f2">,</span><span class="token plain"> addressdetails</span><span class="token operator" style="color:#66d9ef">=</span><span class="token boolean" style="color:#ae81ff">True</span><span class="token punctuation" style="color:#f8f8f2">)</span><br></div></code></pre><button type="button" class="ss-code-copy" aria-label="Copy code to clipboard"><span aria-live="polite">Copy</span></button></div></div>
<p>That query returns the rooftop, with the house number, the road, and a country code. It also returns an annotations block that Nominatim does not provide: timezone, calling code, and the coordinate in six formats. OpenCage charges for the same block. The US results looked good, so we tried European addresses.</p>
<p>It put Warsaw in Indiana.</p>
<p><code>Warsaw, Poland</code> came back at 41.2, -85.8. That point is Warsaw, Indiana, across an ocean from the Warsaw in Poland with one-point-eight million people. Berlin went to New Hampshire. Vienna went to Virginia. Sydney went to Nova Scotia, in Canada and outside the United States altogether. Every city we tried resolved with confidence to a smaller namesake in North America.</p>
<p><a class="" href="https://mailwoman.ai/research/2026/06/07/which-berlin">Which Berlin?</a> covered this kind of collision before. Bare city names repeat across borders, and a later stage has to decide which place you meant. This time was different, because we had built a system that chose the American one every time.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-line-that-hardcoded-america">The line that hardcoded America<a href="https://mailwoman.ai/research/2026/06/26/warsaw-indiana#the-line-that-hardcoded-america" class="hash-link" aria-label="Direct link to The line that hardcoded America" title="Direct link to The line that hardcoded America" translate="no">​</a></h2>
<p>The cause is one line. When we wire the geocoder, there's a country fallback:</p>
<div class="ss-code language-ts codeBlockContainer_mQmQ theme-code-block" style="--prism-color:#f8f8f2;--prism-background-color:#272823"><span class="ss-code-lang">ts</span><div class="ss-code-content"><pre tabindex="0" class="prism-code language-ts codeBlock_RMoD thin-scrollbar" style="color:#f8f8f2;background-color:#272823"><code class="codeBlockLines_AclH"><div class="token-line" style="color:#f8f8f2"><span class="token keyword" style="color:#66d9ef">const</span><span class="token plain"> defaultCountry </span><span class="token operator" style="color:#66d9ef">=</span><span class="token plain"> </span><span class="token function" style="color:#e6db74">resolveCandidateDBPath</span><span class="token punctuation" style="color:#f8f8f2">(</span><span class="token punctuation" style="color:#f8f8f2">)</span><span class="token plain"> </span><span class="token operator" style="color:#66d9ef">?</span><span class="token plain"> </span><span class="token keyword" style="color:#66d9ef">undefined</span><span class="token plain"> </span><span class="token operator" style="color:#66d9ef">:</span><span class="token plain"> </span><span class="token string" style="color:#a6e22e">"US"</span><br></div></code></pre><button type="button" class="ss-code-copy" aria-label="Copy code to clipboard"><span aria-live="polite">Copy</span></button></div></div>
<p>The line looks harmless: "If the ranking database is not loaded, assume the United States." It was harmless at one point, because the model could not route countries on its own, so defaulting to the US was the best available guess for a US-centric demo.</p>
<p>The problem is what <code>defaultCountry</code> does downstream. It is a hard override that takes precedence over every other signal, including the model's own reading of the address. Its name suggests a fallback, but the resolver treats it as final. We have a small classifier whose job is to read "Warsaw, Poland" and output <em>Poland</em>, and it does so correctly and with confidence. The override discarded that answer and substituted "US" before the resolver saw it. We had trained a model to read the country from the address and then ignored its output.</p>
<p>The fix was to delete the line. The model already produced the right country, so the fix was to stop overriding it. Without the override, the country written in the address reaches the resolver. Berlin lands in Germany and Madrid lands in Spain. The five European countries our resolver covers well resolved correctly, and our drop-in went from resolving four of ten test cities to nine of ten, with no change to the American ones.</p>
<p>That fix is correct, and it has shipped.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="four-cities-that-wont-move">Four cities that won't move<a href="https://mailwoman.ai/research/2026/06/26/warsaw-indiana#four-cities-that-wont-move" class="hash-link" aria-label="Direct link to Four cities that won't move" title="Direct link to Four cities that won't move" translate="no">​</a></h2>
<p>The tenth city was Vienna, and closer inspection found three more like it. With the override deleted, four cities still resolve to the wrong country: Vienna to Virginia, Sydney to Nova Scotia, Warsaw to Indiana, and Toronto to Ohio.</p>
<p>These errors come from the name collision itself, with no override involved. The resolver ranks candidates by population, and for most cities that is enough, because no other Tokyo is big enough to outrank Tokyo. For Vienna, Austria to win, it has to outrank a Vienna the resolver reaches first. Without a country weight in the ranking, the ranker never asks which Vienna is biggest. The model assigns the country tag Austria, but on this query the resolver never receives that signal as a binding constraint.</p>
<p>A manual workaround exists today, the same one Nominatim offers. A caller can set <code>countrycodes=au</code> to make Sydney land in Australia, because the resolver honors the restriction. That fixes the simpler collisions. It does not fix the harder ones: <code>Warsaw</code> plus a Poland filter returns no place, because the gazetteer files the record under <code>Warszawa</code> and the English exonym matches no Polish place. That is a separate problem with a separate fix, and we filed it separately.</p>
<p>At this point we had a one-line cause, a one-line fix, and a known residual with a workaround. We wanted to measure the residual: how much of the world's error comes from the model failing to route the country, and how much from other causes? We built a measurement that geocodes each country's most populous cities as bare names and scores them by distance from the truth, across one hundred and eighty-seven countries.</p>
<p>The measurement was wrong, because we did not check which configuration it ran on.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-database-we-never-switched-on">The database we never switched on<a href="https://mailwoman.ai/research/2026/06/26/warsaw-indiana#the-database-we-never-switched-on" class="hash-link" aria-label="Direct link to The database we never switched on" title="Direct link to The database we never switched on" translate="no">​</a></h2>
<p>The first sign was a contradiction in our own notes. The breakdown classified Poland as <em>coverage-absence</em>, meaning the gazetteer had no record at all. Two hours earlier, while investigating the exonym, we had shown by hand that <code>Warszawa, Poland</code> resolves to 52.2, 21.0, exactly right. The record exists. We had a report saying "no Warsaw" next to a transcript that resolved Warsaw.</p>
<p>Both could not be true, so one was measuring the wrong thing, and it was the report. The whole sweep had run with the candidate ranking database switched off. The same <code>resolveCandidateDBPath() ? … : "US"</code> branch from the top of this post applied. This time the missing database did more than change a fallback. It changed which resolver answered every non-US query. We had measured the world on a code path that was never meant to serve it and then recorded the numbers as if they were final.</p>
<p>With the database switched on, the results changed substantially. The sweep had called London a coverage gap. London now resolves two kilometers from the truth, and Beijing does too. Those cities were present all along, in the index we had not loaded.</p>
<p>We retracted the numbers. The report now has a caveat at the top, the two issues we had filed based on it have correction notes, and the retrospective states that the percentages were measured on the wrong configuration. We want this mistake on the record. It is the same kind of mistake as <a class="" href="https://mailwoman.ai/research/2026/06/07/which-berlin">Which Berlin?</a>, a metric grading the wrong thing, one layer lower. In that post, the number was computed correctly but measured the wrong quantity. Here the number measured the right quantity on the wrong configuration. The same practice catches both: before trusting a measurement of the engine, fix and state the data and configuration it ran on. We caught this later than we would have liked, and only because we had written down the contradicting result.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="where-this-leaves-us">Where this leaves us<a href="https://mailwoman.ai/research/2026/06/26/warsaw-indiana#where-this-leaves-us" class="hash-link" aria-label="Direct link to Where this leaves us" title="Direct link to Where this leaves us" translate="no">​</a></h2>
<p>Here is where things stand.</p>
<p>The drop-in works and is shipping. You can point your Nominatim client at it, keep your code, and drop the database stack. The America-by-default bug is fixed: the model routes the country it reads, and <code>countrycodes</code> is available when you want to force one. Those results hold regardless of configuration.</p>
<p>The namesake gap is also real, and it remained after the correction. With the ranking database on, Vienna, Sydney, Warsaw, and Toronto <em>still</em> resolve to the wrong country, because population-first ranking cannot break a same-name tie without a prior on the country. We re-checked this finding with the database loaded, because of the earlier mistake, and verified those four wrong-country results. The model work must improve confident predictions for more countries and make the resolver treat those predictions as binding.</p>
<p>We do not yet know the size of the remaining error categories, such as exonyms like Warsaw versus Warszawa or missing coverage. The only meaningful way to measure them is on the production configuration. We must run that measurement on the production configuration before estimating the category sizes.</p>
<p>Nominatim covers the whole planet on community data and sets the standard we are trying to meet. We can now answer its queries from a file, and on much of the map we answer them well. We also learned that grading a geocoder on the wrong configuration, and forgetting that you did, produces misleading results. We will keep grading on the coordinate and will record the configuration each grade ran on.</p>]]></content:encoded>
            <category>Geocoding</category>
            <category>Resolver / WOF</category>
            <category>International</category>
            <category>Geocoder hubris</category>
            <category>Retrospective</category>
        </item>
        <item>
            <title><![CDATA[A confidence you can route on]]></title>
            <link>https://mailwoman.ai/research/a-confidence-you-can-route-on</link>
            <guid>https://mailwoman.ai/research/a-confidence-you-can-route-on</guid>
            <pubDate>Wed, 24 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[You've got a hundred thousand addresses to reconcile. Two databases, the same clinics and providers scattered across both, each one spelled a dozen ways none.]]></description>
            <content:encoded><![CDATA[<p>You've got a hundred thousand addresses to reconcile. Two databases, the same clinics and providers scattered across both, each one spelled a dozen ways: abbreviated here, reordered there, a postcode dropped, a suite number glued to the street. You run them through a geocoder, match on the resolved coordinate, and it works. Mostly. Some fraction land on the wrong building, the wrong block, the wrong town, and the geocoder won't tell you which fraction. It hands back a pin for every row and the same silent confidence for all of them: none.</p>
<p>That's the gap we set out to close. Every geocoder chases accuracy, be-right-more-frequently, and so do we. The piece that few geocoders hand you is the one underneath it: a number on each answer that tells you <em>which</em> ones to trust, so you can keep the good ones and send the rest to a human.</p>
<!-- -->
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-forecasters-interface">The forecaster's interface<a href="https://mailwoman.ai/research/a-confidence-you-can-route-on#the-forecasters-interface" class="hash-link" aria-label="Direct link to The forecaster's interface" title="Direct link to The forecaster's interface" translate="no">​</a></h2>
<p>Think about a weather forecaster. A good one who forecasts "70% chance of rain" is right on about seven of every ten days they forecast it. That's the whole interface: the number means what it states. A neural model makes the same promise implicitly every time it hands you a softmax probability, and for a long while ours without output broke it, confident in some bands and timid in others, with no single number you could lean on.</p>
<p>We fixed that part first (it's <a href="https://github.com/sister-software/mailwoman/blob/main/docs/records/site-2026-08/concepts/confidence-calibration.mdx" target="_blank" rel="noopener noreferrer" class="">its own story</a>). The short version: a small post-hoc correction maps the model's raw confidence onto a calibrated one, so when a parsed span reads <code>conf="0.94"</code> it's right about 94% of the time, measured on addresses the fit never saw. When the model reports high confidence, you can now believe it.</p>
<p>A calibrated number per <em>span</em> still isn't a number you can act on per <em>answer</em>, though. So we extended it the last step, to the resolved coordinate.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="set-a-precision-threshold">Set a precision threshold<a href="https://mailwoman.ai/research/a-confidence-you-can-route-on#set-a-precision-threshold" class="hash-link" aria-label="Direct link to Set a precision threshold" title="Direct link to Set a precision threshold" translate="no">​</a></h2>
<p>mailwoman returns a coordinate and a confidence you can put a threshold on. Set a bar (call it τ) and accept only the answers the model is at least that sure about. Route everything below it to review.</p>
<p>On a deliberately messy held-out set (lowercased, abbreviated, postcodes stripped, the real-world soup), graded by whether the resolved point lands within 25 km of the truth, the dial does what you'd want:</p>
<table><thead><tr><th>accept above…</th><th style="text-align:right">right-place @25km</th><th style="text-align:right">share of rows answered</th></tr></thead><tbody><tr><td>anything (τ=0)</td><td style="text-align:right">84%</td><td style="text-align:right">67%</td></tr><tr><td>0.80</td><td style="text-align:right">92%</td><td style="text-align:right">49%</td></tr><tr><td>0.94</td><td style="text-align:right">97%</td><td style="text-align:right">16%</td></tr></tbody></table>
<p>Raise the bar and the precision of what you keep climbs from 84% to 97%. The price is coverage: you answer fewer rows, and you pay it knowingly, because the number is direct about where it stands. A search index returns one best guess and one confidence. That gives you no dial at all. You take what it gives and hope.</p>
<p>The part that makes this trustworthy rather than a nice-looking curve is that it holds on data we didn't draw the curve on. Split the messy set in half, fit no new parameters, and the high-confidence answers still beat the low-confidence ones out-of-sample by fourteen points. The model is most unsure exactly where it ought to be: the locales whose coverage we're still building. It has some sense of what it doesn't know.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="what-you-do-with-it">What you do with it<a href="https://mailwoman.ai/research/a-confidence-you-can-route-on#what-you-do-with-it" class="hash-link" aria-label="Direct link to What you do with it" title="Direct link to What you do with it" translate="no">​</a></h2>
<p>Go back to the hundred thousand records. With a threshold you can finally split them in two: the answers the model stands behind and the ones it's hedging on. You auto-accept the first group and move past it. A person looks at the second. The threshold means what you set it to. Set it at 0.95 and your auto-accepted set is right about that frequently, a rate you can put in a report and defend rather than a hope you re-check by hand.</p>
<p>That's the difference between a vibe and a number. A pin with a vibe attached is a thing you re-litigate on every row. A pin with a calibrated confidence is a thing you build a pipeline around.</p>
<p>It's a change rather than a wand. Crank the threshold and you hand more rows to humans. That's the cost, stated directly, and it's yours to set against your own tolerance for a wrong match. What we've done is make the trade legible and put a real number on both sides of it.</p>
<p>It's live now in <code>mailwoman@4.14.0</code>. Pass a calibrator to the parser, read the <code>conf=</code> off the resolved tree, pick your bar. Then stop guessing which answers to trust, and start knowing.</p>]]></content:encoded>
            <category>Geocoding</category>
            <category>Evaluation</category>
            <category>Record matching</category>
            <category>Project motivation</category>
        </item>
        <item>
            <title><![CDATA[We lost to Nominatim in Europe. Then we found out why.]]></title>
            <link>https://mailwoman.ai/research/2026/06/23/we-graded-ourselves-against-the-incumbents</link>
            <guid>https://mailwoman.ai/research/2026/06/23/we-graded-ourselves-against-the-incumbents</guid>
            <pubDate>Tue, 23 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[We ran our 30 MB browser geocoder against the incumbents — Nominatim and Pelias — on real European addresses, scored the direct way. We'd won the US. Europe opened with a double-digit loss. The cause was silence: on messy addresses we returned no result at all. This post covers what caused the empty results, the three fixes that took us clear of both incumbents in Europe, and the one place — Australia — we've now diagnosed but not yet closed.]]></description>
            <content:encoded><![CDATA[<p>Our geocoder had beaten Nominatim across the United States by fifteen points, so we ran the same benchmark on Europe and expected a similar result. Europe produced a double-digit loss.</p>
<p>A loss to Nominatim would have been acceptable, because Nominatim covers the whole planet on community-contributed data and is the standard to beat. We could not explain the loss, though. We knew our parser was not ten points worse in Europe than in America, so we investigated what the gap consisted of.</p>
<p>This post covers the cause, the fixes, and the problems the fixes did not solve. For the European comparison we added a third system: Pelias, through geocode.earth. geocode.earth hosts Pelias on Elasticsearch. Pelias turned out to be the stronger competitor, and this post states where it still beats us.</p>
<!-- -->
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="how-we-kept-score">How we kept score<a href="https://mailwoman.ai/research/2026/06/23/we-graded-ourselves-against-the-incumbents#how-we-kept-score" class="hash-link" aria-label="Direct link to How we kept score" title="Direct link to How we kept score" translate="no">​</a></h2>
<p>We graded on where the address lands on Earth, as we argue geocoders should be graded, and ignored whether the model's labels matched ours. OpenAddresses ships a truth coordinate with every row. For each address we parse it, resolve it to a point, and measure the great-circle distance to the true location. A result within 25 kilometers counts as correctly placed, and a no-result counts as a miss, the same as a wrong answer. Each system gets one number on the same real addresses, and each gets the same country hint.</p>
<p>This standard is stricter than the one we had been using internally. Our internal "resolve rate" counted any resolution as a success, including results that landed a region away. Re-graded at 25 km, the internal metric had overstated our European results by fifteen to twenty points. The benchmark's first effect was to correct our own scoreboard, and we now grade the coordinate.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-us-briefly--because-its-why-europe-stung">The US, briefly — because it's why Europe stung<a href="https://mailwoman.ai/research/2026/06/23/we-graded-ourselves-against-the-incumbents#the-us-briefly--because-its-why-europe-stung" class="hash-link" aria-label="Direct link to The US, briefly — because it's why Europe stung" title="Direct link to The US, briefly — because it's why Europe stung" translate="no">​</a></h2>
<p>In the US, mailwoman correctly places <strong>99%</strong> of these addresses at 25 km, and Nominatim places <strong>84%</strong>. Almost all of the difference is <em>no-result</em>: ours is near zero, and Nominatim's is about one in six. That difference reflects our purpose-built data, with TIGER and a fifty-state rooftop layer covering areas where community OSM data is still incomplete.</p>
<p>We get that result from a 30 MB model that runs in your browser, without Elasticsearch or a server round-trip. The same engine, deployed the same way, ran on Europe. That is why a double-digit loss there was confusing.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="europe-we-werent-wrong-we-were-silent">Europe: we weren't wrong, we were silent<a href="https://mailwoman.ai/research/2026/06/23/we-graded-ourselves-against-the-incumbents#europe-we-werent-wrong-we-were-silent" class="hash-link" aria-label="Direct link to Europe: we weren't wrong, we were silent" title="Direct link to Europe: we weren't wrong, we were silent" translate="no">​</a></h2>
<p>Graded the same way across six European locales, mailwoman started at about <strong>66%</strong>. Nominatim scored <strong>78%</strong>. Pelias uses Elasticsearch and a mix of sources, and it scored <strong>89%</strong>. We were behind both. The obvious explanation is that the parser is worse in Europe, but the data did not support it. When mailwoman <em>does</em> resolve a European address, the point is within a kilometer or two of the truth. Almost the whole gap came from the other failure mode: more than one in four messy European addresses came back <strong>empty</strong>. The model did not place them wrong. It returned no place at all.</p>
<p>Empty results are easier to fix than wrong ones. We investigated why the results were empty and found two separate causes.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="why-it-went-quiet-part-one-a-word-it-mispronounced">Why it went quiet, part one: a word it mispronounced<a href="https://mailwoman.ai/research/2026/06/23/we-graded-ourselves-against-the-incumbents#why-it-went-quiet-part-one-a-word-it-mispronounced" class="hash-link" aria-label="Direct link to Why it went quiet, part one: a word it mispronounced" title="Direct link to Why it went quiet, part one: a word it mispronounced" translate="no">​</a></h2>
<p>The first cause is fragmentation. The subword tokenizer splits a town like <strong>Grudziądz</strong> at the accented <code>ą</code> into <code>Grudzi</code> + <code>dz</code>. Neither half is a place, so neither resolves, and the whole address fails. The full town name was present in the input the entire time. The tokenizer split a word that it had received intact.</p>
<p>The unresolved results include Polish, Czech, and Portuguese towns that the model split at a diacritic and then could not find.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="why-it-went-quiet-part-two-postcodes-we-never-had">Why it went quiet, part two: postcodes we never had<a href="https://mailwoman.ai/research/2026/06/23/we-graded-ourselves-against-the-incumbents#why-it-went-quiet-part-two-postcodes-we-never-had" class="hash-link" aria-label="Direct link to Why it went quiet, part two: postcodes we never had" title="Direct link to Why it went quiet, part two: postcodes we never had" translate="no">​</a></h2>
<p>The second cause was missing data. We had no postcodes for Poland and Czechia. WhosOnFirst, our gazetteer's main source, has rich postcode coverage for some countries and none for others, and PL and CZ had zero rows. A Polish address whose town the model <em>did</em> read correctly could still fail, because the postcode it could have resolved by was absent from the database.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-fixes-read-the-word-and-fill-the-gap">The fixes: read the word, and fill the gap<a href="https://mailwoman.ai/research/2026/06/23/we-graded-ourselves-against-the-incumbents#the-fixes-read-the-word-and-fill-the-gap" class="hash-link" aria-label="Direct link to The fixes: read the word, and fill the gap" title="Direct link to The fixes: read the word, and fill the gap" translate="no">​</a></h2>
<p>For the first cause, when a parse resolves no place, the resolver now returns to the raw text, using the actual characters with diacritics intact instead of the model's subwords. It enumerates the whitespace tokens, matches each against the same-country gazetteer, and takes the longest exact match because a specific town name holds more information than an ambiguous prefix. Before trusting it, we checked that it recovered <em>real coordinates</em>. The recovered towns land about 1.8 km from the truth, and not in a place with the same name elsewhere in the country.</p>
<p>For the second cause, we imported the missing postcodes from GeoNames: about 73,000 for Poland and 15,000 for Czechia, each with its own centroid and an open license. We added them directly to the gazetteer without a model change or retrain.</p>
<p>We did not expect the next result. <strong>The two fixes mostly solve the same cases by different routes.</strong> Where we now have the postcode, the address resolves directly and word recovery has no work to do. Where we lack it, recovery resolves the address. Graded the same way, the postcode fill by itself raised Europe from 66% to 84%. Word recovery by itself also raised it from 66% to 84%. Together they reached <strong>88%</strong>, and the empty-result rate fell from more than one in four to under one in twelve.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-third-fix-stop-being-confidently-wrong">The third fix: stop being confidently wrong<a href="https://mailwoman.ai/research/2026/06/23/we-graded-ourselves-against-the-incumbents#the-third-fix-stop-being-confidently-wrong" class="hash-link" aria-label="Direct link to The third fix: stop being confidently wrong" title="Direct link to The third fix: stop being confidently wrong" translate="no">​</a></h2>
<p>At 88%, a set of misses remained, and these were wrong results instead of empty ones. "06260 Saint-Pierre, La Placette 7" came back <strong>617 kilometers</strong> from the truth. Saint-Pierre is one of the most common town names in France, and we had picked the wrong one. The postcode, 06260, had resolved correctly to the Alpes-Maritimes, where the address is. The coordinate picker trusted the town name and never checked it against the postcode in the same parse, so it discarded the correct answer.</p>
<p>We added that check. When a town lands far from its own postcode, the picker chooses the town of that name nearest the postcode instead. If none is close, it falls back to the postcode's own point. This applies the consistency check from recovery to the main path, without a model change. It raised Europe from 88% to <strong>94%</strong>. After we extended the GeoNames postcode fill to Portugal, Austria, and Australia, it also raised Australia from 35% to 65%, because a resolved postcode gave the disambiguation a reference point.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-direct-part">The direct part<a href="https://mailwoman.ai/research/2026/06/23/we-graded-ourselves-against-the-incumbents#the-direct-part" class="hash-link" aria-label="Direct link to The direct part" title="Direct link to The direct part" translate="no">​</a></h2>
<p>We have been wrong before by declaring success early, so here are the numbers without interpretation. Graded the same way on the European set, all three systems side by side: mailwoman <strong>94%</strong>, Nominatim <strong>78%</strong>, and Pelias <strong>89%</strong>. We now lead both, by five points over a hosted Elasticsearch stack and sixteen over Nominatim, with a 30 MB model running in a browser tab. We lead both systems in Italy, Portugal, France, and Czechia.</p>
<p>Across all seven locales including Australia, the results are <strong>90% for us, 81% Nominatim, and 88% Pelias</strong>. We still lead, but the margin is thin, and Australia accounts for all of the narrowing: we score <strong>65%</strong> there to Nominatim's 97. We have now worked out that Australia is a <em>parsing</em> problem. Australian addresses in this set lead with the postcode ("3053 Carlton, Barry Street 50"). Our model, trained on American and European formats where the number comes first, reads the leading 3053 as a house number and mislabels everything after it. Given the <strong>same address reordered</strong> in the American format, it parses correctly, and 65% becomes 87%. The fix belongs in the model: we will train it on the Australian format using the national address file. Australia remains unfixed. We know the cause, and it lies in the parser. The reordered addresses resolve with the existing gazetteer, so the gazetteer needs no change.</p>
<p>There is also a precision cost. At 25 km we're competitive, and at one kilometer we are not. We resolve to admin and postcode <strong>centroids</strong>, so we place the <em>area</em> correctly, within a couple of kilometers, where Nominatim and Pelias place a rooftop pin. The headline result therefore means the right neighborhood and not the right building. A neighborhood-level result answers an area-level question. A rooftop result requires building-level data.</p>
<p>Word recovery stays <strong>off by default</strong>, because about one in six of the <em>newly</em> recovered addresses lands more than 25 km away when recovery matched the wrong place with that name. Where we have postcode coverage, a consistency check rejects those before they are returned. The GeoNames fill means that check now runs for Poland and Czechia, where a month ago it could not. Australia is where it still cannot run. A batch pipeline therefore leaves recovery off, because a wrong coordinate corrupts a join without any visible error. The demo turns recovery on, because in an interactive tool a visible, correctable result is more useful than no result. Every recovered place is tagged with whether the postcode check ran, so each caller can decide how to treat it.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-thing-a-scoreboard-cant-show">The thing a scoreboard can't show<a href="https://mailwoman.ai/research/2026/06/23/we-graded-ourselves-against-the-incumbents#the-thing-a-scoreboard-cant-show" class="hash-link" aria-label="Direct link to The thing a scoreboard can't show" title="Direct link to The thing a scoreboard can't show" translate="no">​</a></h2>
<p>A single resolve rate also leaves out confidence. A search index returns a result without saying how much to trust it. Mailwoman's confidence is <em>calibrated</em>: a span it marks 90% is correct about 90% of the time, measured on held-out data. You can build a rule on that number, such as auto-accepting above a threshold and routing the rest to a human reviewer, and the threshold behaves as you set it. The recovered-but-unverified coordinates above are flagged separately and are not reported as calibrated probabilities, because the calibrated number is only useful if it stays accurate.</p>
<p>Here is where we stand. Pelias is ahead of us across the full panel, and it places rooftops where we place neighborhoods. In Europe we beat Nominatim by a wide margin and matched Pelias, using 30 megabytes in a browser, without Elasticsearch, and with a calibrated confidence number. We still trail on parts of the map, and Australia is unsolved. We mostly closed a double-digit gap by reading town names that the tokenizer had been splitting and by adding postcodes we should have shipped from the start. We will keep grading on the coordinate.</p>]]></content:encoded>
            <category>Evaluation</category>
            <category>International</category>
            <category>Geocoding</category>
            <category>Resolver / WOF</category>
        </item>
        <item>
            <title><![CDATA[Right on the map, wrong on the test]]></title>
            <link>https://mailwoman.ai/research/2026/06/22/right-on-the-map-wrong-on-the-test</link>
            <guid>https://mailwoman.ai/research/2026/06/22/right-on-the-map-wrong-on-the-test</guid>
            <pubDate>Mon, 22 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Our parser scores 28% on Spanish addresses by the metric that grades its labels. By the metric that grades where the address lands on Earth, it's within a couple of kilometers. Same model, same addresses. Only one of those numbers is measuring the thing we ship.]]></description>
            <content:encoded><![CDATA[<p>Our parser scores 28% on Spanish addresses. By the metric that reads its labels, it is, on paper, broken in a country of forty-seven million people.</p>
<p>That metric is measuring the wrong thing. The moment we measured the right one, the panic fell apart.</p>
<!-- -->
<p>We've <a class="" href="https://mailwoman.ai/research/2026/06/08/the-right-name-in-the-wrong-state">told this story once before</a>, about a resolver that scored 93.7% on a name-match metric while landing 326 kilometers from the truth. We thought we'd learned the lesson — grade the coordinate rather than the word — and filed it under "things we now know." This is that same lesson knocking again, in a new accent, and catching us flat-footed in a new way. We are writing it up because the second time you fall for a trick is the one that should embarrass you into never falling for it again.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="a-test-that-grades-the-words-rather-than-the-place">A test that grades the words rather than the place<a href="https://mailwoman.ai/research/2026/06/22/right-on-the-map-wrong-on-the-test#a-test-that-grades-the-words-rather-than-the-place" class="hash-link" aria-label="Direct link to A test that grades the words rather than the place" title="Direct link to A test that grades the words rather than the place" translate="no">​</a></h2>
<p>Here is the number that started it. We took 120 real Spanish addresses — pulled from OpenAddresses rather than invented — and ran them through the model, then scored its component labels against the truth: did it tag the street as <code>street</code>, the city as <code>locality</code>, and so on. Macro-F1 came back at <strong>28.5%</strong>. Street by itself: <strong>24.9%</strong>. By that yardstick, the model can barely read Spanish.</p>
<p>You can talk yourself into a panic from a number like that. We almost did.</p>
<p>The trouble is what the number is counting. A Spanish street is <code>Calle Mayor</code>, <code>Avenida de la Constitución</code> — a type word, then a name. Our gold tags the whole span as the street, and the strict match wants the model's span to line up with it exactly. It mostly doesn't, and we wanted to know why: is the model directly splitting the type word off — calling <code>Calle</code> a prefix and <code>Mayor</code> the street, a harmless tagging-convention difference — or is it in fact losing the street? Therefore, we re-graded against a bare-name gold, <code>Mayor</code> with the type word stripped from both sides. If the model were splitting <code>Calle</code> off, that should <em>reward</em> it. It did the opposite: street-F1 fell from 25% to 2%. The model's span matches the whole <code>Calle Mayor</code> far more frequently than the bare name — so it's keeping the type word in rather than splitting it off. Its boundaries only don't sit where our gold puts them, and the strict label match scores every such mismatch a flat zero, whether the address was understood or mangled.</p>
<p>A test like that can only tell you whether the model used your exact words. It makes no statement about whether it found the building.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="so-we-graded-the-coordinate">So we graded the coordinate<a href="https://mailwoman.ai/research/2026/06/22/right-on-the-map-wrong-on-the-test#so-we-graded-the-coordinate" class="hash-link" aria-label="Direct link to So we graded the coordinate" title="Direct link to So we graded the coordinate" translate="no">​</a></h2>
<p>The direct question isn't "did it use my labels." It's "did the address land in the right place on Earth." OpenAddresses ships a truth coordinate with every row, so we can ask it directly: parse the address, resolve it to a point, measure the great-circle distance to where it really is. We built held-out sets of 150 real addresses each across the locales where we have that coordinate truth, and graded the production model on the metric we ship.</p>
<p>One subtlety changes the interpretation, so it gets its own column. We separate <strong>how frequently</strong> the model produces a resolvable answer from <strong>how good</strong> that answer is when it does. Lump them together and a locale that fails half the time but nails the other half reads as mediocre everywhere. That reading is exactly wrong. Pull them apart and the model tells you something useful.</p>
<table><thead><tr><th>locale</th><th style="text-align:right">resolves</th><th style="text-align:right">median error, when it resolves</th></tr></thead><tbody><tr><td>🇫🇷 France</td><td style="text-align:right">80%</td><td style="text-align:right">1.3 km</td></tr><tr><td>🇮🇹 Italy</td><td style="text-align:right">79%</td><td style="text-align:right">2.1 km</td></tr><tr><td>🇱🇺 Luxembourg</td><td style="text-align:right">57%</td><td style="text-align:right">0.3 km</td></tr><tr><td>🇵🇱 Poland</td><td style="text-align:right">53%</td><td style="text-align:right">5.8 km</td></tr><tr><td>🇵🇹 Portugal</td><td style="text-align:right">52%</td><td style="text-align:right">1.2 km</td></tr><tr><td>🇦🇹 Austria</td><td style="text-align:right">50%</td><td style="text-align:right">5.2 km</td></tr><tr><td>🇨🇿 Czechia</td><td style="text-align:right">43%</td><td style="text-align:right">44 km</td></tr><tr><td>🇦🇺 Australia</td><td style="text-align:right">28%</td><td style="text-align:right">234 km</td></tr></tbody></table>
<p>Spain isn't in that table — its open-address data is cadastral, with no clean coordinate to grade against, so for Spanish we can only score labels. We can't put a Spanish address on the map in this test and measure it directly, and we won't pretend otherwise. What we <em>can</em> do is take the exact street-boundary quirk that tanked the Spanish label score and watch what it costs next door, in the Romance-language locales we can measure. The answer is: no measurable cost. Where the model resolves an Italian or Portuguese address at all, it lands within a few kilometers — city grain, sometimes the rooftop. Luxembourg comes back at 300 meters. The model gets the locality and the postcode right frequently enough to find the right town while it draws the street boundary somewhere the label test hates. The 28% was never measuring geocoding. It was measuring vocabulary — and the redemption holds everywhere we have a coordinate to check it against. That is the strongest claim the data lets us make for Spanish without Spanish coordinates.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-gap-is-how-frequently-it-answers-rather-than-how-well">The gap is how frequently it answers rather than how well<a href="https://mailwoman.ai/research/2026/06/22/right-on-the-map-wrong-on-the-test#the-gap-is-how-frequently-it-answers-rather-than-how-well" class="hash-link" aria-label="Direct link to The gap is how frequently it answers rather than how well" title="Direct link to The gap is how frequently it answers rather than how well" translate="no">​</a></h2>
<p>So that's the good half, and we'll take it. The direct half is the left column, and it's where the real work is.</p>
<p>Look down the <code>resolves</code> rate and a pattern falls out that is unrelated to accuracy and is entirely about us. France and Italy — the two locales we've poured training data into — resolve about 80% of the time. Everywhere we haven't, the rate drops by a third or more: Portugal, Poland, and Austria resolve around half the time, and Czechia resolves at 43. Australia resolves at 28 and misplaces what little it does resolve. We've barely touched Australia, and its own town names collide across states. The model isn't worse at <em>understanding</em> those addresses. It's worse at producing an answer for them at all, and the cliff lines up exactly with where our corpus stops.</p>
<p>That's the uncomfortable, useful shape of it. The wall isn't precision — when the model commits to an answer abroad, the answer is good. One asterisk applies, and it's a candid one: those are the answers the model <em>chose</em> to give, and the addresses it drops skew toward the harder ones, so read the resolved distances as a ceiling rather than an average. The wall is recall, and recall is a map of our own training data drawn in someone else's country.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="not-the-gazetteers-fault">Not the gazetteer's fault<a href="https://mailwoman.ai/research/2026/06/22/right-on-the-map-wrong-on-the-test#not-the-gazetteers-fault" class="hash-link" aria-label="Direct link to Not the gazetteer's fault" title="Direct link to Not the gazetteer's fault" translate="no">​</a></h2>
<p>There's a comfortable version of this story where the model parses everything correctly and the <em>gazetteer</em> is missing half of Poland, and the fix is a data import that reflects badly on no one. We checked, because that's the version we'd have preferred. It isn't true.</p>
<p>When a Portuguese address fails to resolve, it's not because the town is absent from our index. We folded the whole world's place names into the index months ago, and a spot check confirmed every Polish and Portuguese locality GeoNames knows is already there. It's that the model never directly emits the locality in the first place. The label test agrees, for once: Portuguese <code>locality</code>-recall sits at 39%, right alongside its 52% resolve rate. If the gazetteer were the bottleneck, the model would extract the town and the index would fail to find it. Instead the model and the resolver fail together, in lockstep. The places are in the index. The model doesn't reliably ask for them. We ran that check directly for Portuguese. Across the rest of the mid-tier the same shape repeats: low resolve, low locality-recall, and towns that the spot check found in the index. That pattern is what you'd expect if the cause is the same one.</p>
<p>Which tells us what <em>not</em> to do. There's no clever rule, no <code>Calle</code>-handling special case, that fixes this — bolt on a Spanish street splitter and you've taught the parser one convention by hand and learned no general rule for the next forty. The model didn't fail because it lacked a rule. It failed because it hasn't seen enough of the place. The change is the training distribution, and we now have a coordinate-graded yardstick that will tell us, locale by locale, whether feeding it more moves the recall it's supposed to.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="what-were-keeping">What we're keeping<a href="https://mailwoman.ai/research/2026/06/22/right-on-the-map-wrong-on-the-test#what-were-keeping" class="hash-link" aria-label="Direct link to What we're keeping" title="Direct link to What we're keeping" translate="no">​</a></h2>
<p>The lesson is the same one as last time. We had it, written down, and the label metric still walked us to the edge of the wrong conclusion before the coordinate pulled us back. Grade the thing you ship. Distance to the truth can't be talked out of, can't be gamed by matching a word, and doesn't care which language the street type is in. Every other metric is a proxy, and proxies flatter you in the languages you trained on and slander you in the ones you didn't.</p>
<p>And then there's the quieter half, the one the two columns made impossible to ignore: a model can be <em>right</em> and <em>unavailable</em> at the same time, and if you only measure one number you'll never know which problem you have. Pull recall apart from precision before you decide what's broken. Ours turned out to be accurate everywhere it answers — and the whole job now is getting it to answer more frequently, in more places, by showing it more of the world.</p>]]></content:encoded>
            <category>Evaluation</category>
            <category>Geocoding</category>
            <category>International</category>
            <category>Multilingual</category>
        </item>
        <item>
            <title><![CDATA[We shipped 'world coverage.' It covered 97 countries.]]></title>
            <link>https://mailwoman.ai/research/world-coverage-97-countries</link>
            <guid>https://mailwoman.ai/research/world-coverage-97-countries</guid>
            <pubDate>Sun, 21 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Open the demo and type an address in Kabul, and the demo returns no result. It does not place a pin a few streets off or fall back to the city center, because the gazetteer has no entry for Kabul. Hong Kong, Tirana, Chişinău, and every place in the Democratic Republic of the Congo return no result as well. We had been calling this gazetteer "world coverage" for weeks, and it covered 97 of the world's ~195 countries. The other ninety-odd countries were absent from the file.]]></description>
            <content:encoded><![CDATA[<p>Open the <a class="" href="https://mailwoman.ai/demo">demo</a> and type an address in Kabul, and the demo returns no result. It does not place a pin a few streets off or fall back to the city center, because the gazetteer has no entry for Kabul. Hong Kong, Tirana, Chişinău, and every place in the Democratic Republic of the Congo return no result as well. We had been calling this gazetteer "world coverage" for weeks, and it covered <strong>97 of the world's ~195 countries</strong>. The other ninety-odd countries were absent from the file.</p>
<!-- -->
<p>This gap did not come from a single dramatic mistake. It built up without any error, the way most data gaps do, and the most useful part of this post is the audit that finally exposed it.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="a-missing-place-never-files-a-complaint">A missing place never files a complaint<a href="https://mailwoman.ai/research/world-coverage-97-countries#a-missing-place-never-files-a-complaint" class="hash-link" aria-label="Direct link to A missing place never files a complaint" title="Direct link to A missing place never files a complaint" translate="no">​</a></h2>
<p>Coverage gaps are hard to see because a missing place does not raise an error. When a parser mislabels a token, the wrong field shows up in a diff. When a resolver picks the wrong Springfield, the pin lands in the wrong state and a test fails. When a place is absent from the gazetteer, the system behaves exactly as it does for a typo or a fictional street: it returns no result. Afghanistan returning no result looks identical to "Hgsdfgh Street" returning no result.</p>
<p>You can run the eval suite, see it pass, ship, and announce world coverage while every number you report is accurate. The US accuracy is real, and so is the German coordinate error. Those numbers are measured against the places that <em>are</em> in the file, so the file determines what gets measured. We were grading ourselves on the 97 countries we had, and the missing countries never entered the average.</p>
<p>This applies to anyone building on open geodata: <strong>your coverage is your sources' coverage, and you inherit its gaps without any warning.</strong> We build the gazetteer from Who's On First plus Overture's administrative divisions. Both are high-quality sources, and we would choose them again. High quality does not mean complete, though, and the admin DB built from those two sources contains localities for 97 countries. We never decided to drop Libya. It was never in the data, and no stage of the pipeline was designed to detect the absence.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-trick-audit-against-a-yardstick-you-didnt-build">The trick: audit against a yardstick you didn't build<a href="https://mailwoman.ai/research/world-coverage-97-countries#the-trick-audit-against-a-yardstick-you-didnt-build" class="hash-link" aria-label="Direct link to The trick: audit against a yardstick you didn't build" title="Direct link to The trick: audit against a yardstick you didn't build" translate="no">​</a></h2>
<p>Your own data cannot reveal gaps that are absent from it. Compare it against an independent source and check where the two disagree.</p>
<p>We did the cheapest version of that comparison. GeoNames publishes <code>cities15000</code>, a flat file of every populated place on Earth with more than 15,000 people. We already had it on disk. For each country, we counted the localities in our gazetteer and the cities in GeoNames and printed the ratio. The audit took about a minute to write. Most countries had reasonable ratios, and a long column of countries had zero:</p>
<div class="ss-code language-text codeBlockContainer_mQmQ theme-code-block" style="--prism-color:#f8f8f2;--prism-background-color:#272823"><span class="ss-code-lang">text</span><div class="ss-code-content"><pre tabindex="0" class="prism-code language-text codeBlock_RMoD thin-scrollbar" style="color:#f8f8f2;background-color:#272823"><code class="codeBlockLines_AclH"><div class="token-line" style="color:#f8f8f2"><span class="token plain">country  ours  GeoNames cities&gt;15k</span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain">AF       0     54</span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain">HK       0     141</span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain">KP       0     97</span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain">CD       0     114</span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain">AL       0     25</span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain">...</span><br></div></code></pre><button type="button" class="ss-code-copy" aria-label="Copy code to clipboard"><span aria-live="polite">Copy</span></button></div></div>
<p>Hong Kong has a hundred and forty-one cities in the reference and zero in ours. Model settings cannot fill a gap of that size. We checked whether the candidate build was filtering out data that existed, and it was not: the source admin DB itself stops at 97 countries. The gap was real and came from upstream.</p>
<p>This external comparison was the most productive hour of the whole effort, and it was simple. It needed no model or new metric. It needed a comparison of our file against someone else's count. The main recommendation of this post is to compare your coverage against a reference you did not build, along whatever dimension matters to you. A gap you cannot see still limits your results.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="a-data-update-fills-the-gap-without-retraining">A data update fills the gap without retraining<a href="https://mailwoman.ai/research/world-coverage-97-countries#a-data-update-fills-the-gap-without-retraining" class="hash-link" aria-label="Direct link to A data update fills the gap without retraining" title="Direct link to A data update fills the gap without retraining" translate="no">​</a></h2>
<p>A pure data gap needs only a data fix. It needs no GPU, model, or retrain, because GeoNames already has the places, with coordinates and per-language names, for every country we were missing. We extended our gap-fill builder to register country codes it had not seen before and ran it on the full <code>allCountries</code> dump, keeping only current populated places and dropping historical and abandoned ones.</p>
<p>The result is <strong>4.6 million town-level localities across 248 countries</strong>, up from 97. We also checked a handful of major cities in the previously empty countries because each result must place the city correctly. Kabul resolves to Kabul within a few hundred meters. Hong Kong, Tirana, Tripoli, Sarajevo, and Yerevan also resolve correctly. All nine checked cities landed within thirty kilometers, and most were exact. A spot-check of small towns placed them inside the correct country's bounding box two hundred times out of two hundred.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-cost">The cost<a href="https://mailwoman.ai/research/world-coverage-97-countries#the-cost" class="hash-link" aria-label="Direct link to The cost" title="Direct link to The cost" translate="no">​</a></h2>
<p>The change has a cost. Full town-level depth grows the gazetteer from 811 MB to 1.26 GB. That size costs little on a server but matters in the browser, where the demo streams the database a few kilobytes at a time over byte-range requests, and a file about 50% larger means about 50% more data to range over. The fix is therefore two builds. The browser gets the major cities. That build is light and covers most of what people type. The CLI and server get the full town depth, where storage is cheap. One builder produces both depths, and you choose which artifact gets which.</p>
<p>A place <em>resolving</em> is also different from a full address <em>parsing</em>. With Kandahar in the gazetteer, the resolver now places the word "Kandahar" in Afghanistan instead of returning no result. Whether our parser reliably extracts "Kandahar" from a raw Afghan address string is a separate question for a separate post. Coverage was a prerequisite, and this work completed it.</p>
<p>Compare your data against a reference you did not build, count the disagreements, and look closely at the zeros. Our gap passed every test for weeks before this audit found it. Your data probably has one too.</p>]]></content:encoded>
            <category>Geocoding</category>
            <category>Resolver / WOF</category>
            <category>Corpus pipeline</category>
        </item>
        <item>
            <title><![CDATA[243 round trips to find a city]]></title>
            <link>https://mailwoman.ai/research/243-round-trips-to-find-a-city</link>
            <guid>https://mailwoman.ai/research/243-round-trips-to-find-a-city</guid>
            <pubDate>Sat, 20 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[The whole geocoder runs in your browser. You type an address, you get a rooftop coordinate, and no server ever sees your query — the gazetteer it resolves against is a SQLite database sitting on a CDN, and the page reads it with HTTP range requests, a few kilobytes at a time. It's a lovely trick. We were proud of it. Then we counted the requests it took to find a single city, and the number was 243.]]></description>
            <content:encoded><![CDATA[<p>The whole geocoder runs in your browser. You type an address, you get a rooftop coordinate, and no server ever sees your query — the gazetteer it resolves against is a SQLite database sitting on a CDN, and the page reads it with HTTP range requests, a few kilobytes at a time. It's a lovely trick. We were proud of it. Then we counted the requests it took to find a single city, and the number was 243.</p>
<p>So the questions for the day: why does looking up one name cost 243 round trips? What goes wrong when you search a database you can only read a byte range at a time? And how do you get a global gazetteer — every country, region, county, and city we resolve against — down to about a dozen reads without putting a server back in the loop?</p>
<!-- -->
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="range-requests-expose-database-lookup-costs">Range requests expose database lookup costs<a href="https://mailwoman.ai/research/243-round-trips-to-find-a-city#range-requests-expose-database-lookup-costs" class="hash-link" aria-label="Direct link to Range requests expose database lookup costs" title="Direct link to Range requests expose database lookup costs" translate="no">​</a></h2>
<p>The constraint explains the design. The admin database is 2.6 GB. No user will download 2.6 GB to type "Chicago" into a search box. What makes this work at all is <code>sql.js-httpvfs</code>: a SQLite build whose file-reading layer, instead of reading from disk, issues HTTP <code>Range</code> requests against the file on the CDN. SQLite asks for "bytes 4,210,688 through 4,276,224," the VFS turns that into a range GET, the CDN hands back that one 64 KB chunk, and the query engine continues as if the file were local. You're reading a multi-gigabyte database through a straw, and most of the time you never touch 99.99% of it.</p>
<p>For a B-tree lookup on a primary key, this is close to magic. The engine walks root → interior → leaf, and each hop is one range request. Three or four reads and you're holding your row. The straw is wide enough.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="why-search-scatters">Why search scatters<a href="https://mailwoman.ai/research/243-round-trips-to-find-a-city#why-search-scatters" class="hash-link" aria-label="Direct link to Why search scatters" title="Direct link to Why search scatters" translate="no">​</a></h2>
<p>The trouble started because we weren't doing a primary-key lookup. We were doing a name search, and to make name search fast inside SQLite you reach for FTS5 — full-text search, an inverted index. For each token it stores a posting list: every row that contains it. Search becomes "go read the posting list for this token," which is exactly the kind of thing a real database does well when the disk is right there.</p>
<p>The disk is not right there. It's at the end of a straw. And an FTS posting list for a common name is not one tidy run of bytes — it's a chain of pages scattered through the file, because the index grew as the data loaded and the postings landed wherever there was room. A client reads one posting list by following that chain, and every hop in the chain is its own range request. A common place name dragged in a long chain. Then the candidate rows themselves lived somewhere else again. The reads piled up: 243 of them, about 16.5 MB, to answer one query.</p>
<p>The maddening part came when we tried to tune it. The VFS lets you widen the straw — ask for bigger chunks per request, fetch 64 KB or 256 KB at a swing. We widened it, expecting fewer round trips. We got more bytes and barely fewer trips, because the postings were scattered, so a bigger chunk mostly fetched neighbors we didn't want. We'd been getting away with it on a "slim" database — a stripped 53 MB build with three countries in it — and the slim build was cheap for the dullest possible reason: it was small, so even a scattered search couldn't scatter very far. That's not an optimization. That's a database too little to misbehave.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="stop-searching-start-addressing">Stop searching. Start addressing.<a href="https://mailwoman.ai/research/243-round-trips-to-find-a-city#stop-searching-start-addressing" class="hash-link" aria-label="Direct link to Stop searching. Start addressing." title="Direct link to Stop searching. Start addressing." translate="no">​</a></h2>
<p>We talked it over with DeepSeek, and the reframe we kept from that discussion was this: a database you read through a range request isn't a database you should <em>search</em>. It's a database you should <em>address</em>. Don't make the browser hunt for the answer across an index — precompute the answer and put it where one short read can reach it.</p>
<p>Concretely: a single table, one row per name a place can be found by. Each row stores everything the resolver needs already on it — the canonical name, the centroid, the bounding box, the country, and the population rank baked in as a sortable column — so once you've found the row, you're done, with no second trip to go fetch the details. The table is <code>WITHOUT ROWID</code>, which in SQLite means the rows <em>are</em> the B-tree, sorted by the key, and the key is the normalized name. We load it pre-sorted and <code>VACUUM</code> it so a name and its neighbors sit on adjacent pages. Now a lookup is the thing the straw was always good at: walk the tree to the name, read the leaf, and the matches for "springfield" are sitting there in a row, ranked, ready.</p>
<p>One discipline holds the whole thing up: the key has to be normalized the <em>same</em> way when we build the table and when we query it, or the walk lands one page off and finds no entry. So both sides call the same function — <code>normalizeLocalityForKey</code> — by construction rather than by convention. It folds case, strips diacritics so <code>Saint-Étienne</code> keys as <code>saint-etienne</code>, and leaves scripts that aren't Latin unchanged so <code>北京</code> stays <code>北京</code>. Build-side and query-side can't drift, because there's only one of it.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-cost-we-almost-shipped">The cost we almost shipped<a href="https://mailwoman.ai/research/243-round-trips-to-find-a-city#the-cost-we-almost-shipped" class="hash-link" aria-label="Direct link to The cost we almost shipped" title="Direct link to The cost we almost shipped" translate="no">​</a></h2>
<p>Now the catch. The candidate table holds US postcodes and not yet the rest of the world's, the same coverage the old slim build had. So when we first wired it up and typed a Berlin address — <code>10115</code> — the demo dropped a pin in Manhattan. <code>10115</code> is a real Berlin postcode <em>and</em> a real New York ZIP, the table only knew the New York one, and the cascade had reached for the postcode first and taken the only match it found.</p>
<p>The fix wasn't more postcodes. It was order. Resolve the locality first — "Berlin," which ranks to Germany by population — then let that resolved place check the postcode lookup to its own country. No German <code>10115</code> in the table? Fine: fall through to Berlin's own centroid. That centroid is exactly where the address wanted to be. An ambiguous postcode can't drag a German address across the Atlantic anymore, because it never gets to vote before the city does.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="what-it-bought">What it bought<a href="https://mailwoman.ai/research/243-round-trips-to-find-a-city#what-it-bought" class="hash-link" aria-label="Direct link to What it bought" title="Direct link to What it bought" translate="no">​</a></h2>
<p>A name now resolves in about 12 range requests and under a megabyte, down from 243 and 16.5 MB. The whole table is 490 MB with <em>global</em> coverage — every place we know rather than three countries — in a single file the browser reads a dozen pages of. US locality resolution holds at 96.8%; the European cities we'd added through Overture come back at parity. And the slim build — the per-model-version, three-country database we'd been maintaining — is gone, along with the chore of deciding which countries to ship.</p>
<p>The result is still supported because the constraint was never going away. When the only way to touch your data is one short read at a time, searching is the expensive verb. So we stopped searching. We built the answer ahead of time and gave it an address, and now the browser goes and reads it.</p>]]></content:encoded>
            <category>Geocoding</category>
            <category>Resolver / WOF</category>
            <category>Infrastructure</category>
        </item>
        <item>
            <title><![CDATA[843,000 postcodes and no Canada]]></title>
            <link>https://mailwoman.ai/research/843000-postcodes-and-no-canada</link>
            <guid>https://mailwoman.ai/research/843000-postcodes-and-no-canada</guid>
            <pubDate>Sat, 20 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Overnight I taught the geocoder Canada. Or I thought I did. I pulled 843,000 Canadian postcodes, computed a centroid for every one, spot-checked the result — M5H 2N2, downtown Toronto, 43.652, −79.382, dead on — and validated the database every way I could read it: every postcode present, every coordinate right, and no other data disturbed. Green, top to bottom. This morning, before flipping it live, I asked the demo to find a Toronto address. It dropped the pin in Ohio.]]></description>
            <content:encoded><![CDATA[<p>Overnight I taught the geocoder Canada. Or I thought I did. I pulled 843,000 Canadian postcodes, computed a centroid for every one, spot-checked the result — <code>M5H 2N2</code>, downtown Toronto, 43.652, −79.382, dead on — and validated the database every way I could read it: every postcode present, every coordinate right, and no other data disturbed. Green, top to bottom. This morning, before flipping it live, I asked the demo to find a Toronto address. It dropped the pin in Ohio.</p>
<p>So, the questions for the morning. How does a database that passes every check still land the answer 600 kilometers wrong? What does it mean to "validate" a thing? And why is Toronto in Ohio?</p>
<!-- -->
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-build-that-felt-right">The build that felt right<a href="https://mailwoman.ai/research/843000-postcodes-and-no-canada#the-build-that-felt-right" class="hash-link" aria-label="Direct link to The build that felt right" title="Direct link to The build that felt right" translate="no">​</a></h2>
<p>The task was simple enough: add Canada to the map. The obvious change is postcodes — they're precise, they're public, and Overture ships them. I'd done exactly this for a dozen European countries the night before, same tool, same shape: read the country's address points, group by postcode, take a trimmed centroid, write one row per code. Canada came back with 843,000 of them, alphanumeric <code>A1A 1A1</code> codes that survive the build intact, each pinned to its own block. I spot-checked Toronto's financial district and it sat exactly where it should. I diffed the new gazetteer against the old — US postcodes untouched, every European set intact, 843,000 new Canadian rows. Every number I could check, I checked, and every one was right.</p>
<p>That is the trap. I had checked everything I'd built.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-check-that-grades-the-pin">The check that grades the pin<a href="https://mailwoman.ai/research/843000-postcodes-and-no-canada#the-check-that-grades-the-pin" class="hash-link" aria-label="Direct link to The check that grades the pin" title="Direct link to The check that grades the pin" translate="no">​</a></h2>
<p>There's one test in the stack that doesn't care what I built. It loads the actual demo in a real browser, types an address into the box, and reads the coordinate off the map. The pin the user would see. The whole point of it is to grade the <em>assembled</em> answer rather than any single piece of the implementation. Before promoting the new gazetteer I added a Canadian case to it: <code>100 Queen Street West, Toronto, ON M5H 2N2</code>, and assert the pin lands somewhere in Toronto.</p>
<p>Four cases passed — Chicago, a Berlin postcode, the White House, all green. The fifth came back 40.458, −80.6. I had to look it up. That's Toronto, Ohio: population five thousand, on the bank of the Ohio River, about as far from the address I typed as Madrid is from London.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="why-the-perfect-postcode-never-got-a-vote">Why the perfect postcode never got a vote<a href="https://mailwoman.ai/research/843000-postcodes-and-no-canada#why-the-perfect-postcode-never-got-a-vote" class="hash-link" aria-label="Direct link to Why the perfect postcode never got a vote" title="Direct link to Why the perfect postcode never got a vote" translate="no">​</a></h2>
<p>The gazetteer has two layers. There's the postcode layer I'd rebuilt, and underneath it the admin layer — the one that records a place's country and population. So I went and counted the admin layer: 1.7 million places, twenty-six countries. The number of Canadian ones is zero. Toronto, Montréal and Vancouver are all missing — Canada isn't in it at all. We'd built the gazetteer out of the European set, the US, and the CJK countries, and no build step had ever folded Canada in.</p>
<p>Now watch what that does. The parser reads "Toronto" off the address and hands it to the resolver. The only Toronto the resolver has ever heard of is the one in Ohio, so that's the city it picks. And then the cleverest piece of the cascade turns around and finishes the job. We added that piece a couple of weeks ago to keep Berlin's <code>10115</code> — which is also a perfectly good New York ZIP — from dragging German addresses to Manhattan: resolve the city first, then let that city restrict the postcode to its own country. It's a good rule. Here it's a guillotine. The city resolved to the United States, so the correct Canadian postcode is now "from the wrong country," and out it goes. All 843,000 of my perfect coordinates never get a vote, because the city votes first and the city is in Ohio.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="i-validated-the-layer-i-made-rather-than-the-one-that-ships">I validated the layer I made rather than the one that ships<a href="https://mailwoman.ai/research/843000-postcodes-and-no-canada#i-validated-the-layer-i-made-rather-than-the-one-that-ships" class="hash-link" aria-label="Direct link to I validated the layer I made rather than the one that ships" title="Direct link to I validated the layer I made rather than the one that ships" translate="no">​</a></h2>
<p>The lesson is an old one. I validated the postcodes. The postcodes were never the problem. I checked the thing I'd built and not the thing the user receives — the coordinate, the pin, the assembled output of every layer working together. Each green check was accurate and beside the point, because not one of them graded the only number that matters.</p>
<p>This keeps happening to us, in different forms. A model's per-tag F1 climbs while the coordinate it produces goes nowhere. A reconcile pass improves a label and breaks the geocode that depended on it. The discipline that survives every version of it is the same: grade the assembled coordinate against the truth rather than the component you happened to touch. The e2e check isn't ceremony. It's the one thing in the stack that reads the pin the user sees, and it's the only thing that caught this.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="what-it-costs-and-what-it-doesnt">What it costs, and what it doesn't<a href="https://mailwoman.ai/research/843000-postcodes-and-no-canada#what-it-costs-and-what-it-doesnt" class="hash-link" aria-label="Direct link to What it costs, and what it doesn't" title="Direct link to What it costs, and what it doesn't" translate="no">​</a></h2>
<p>The accounting: the night wasn't wasted. The 843,000 centroids are correct and sitting ready, and the tooling that made them is the same tooling that'll make the next country. But the premise was half a country short. A country, it turns out, is its admin skeleton first — the cities and regions that anchor a name to a place — and a postcode only means something once that skeleton is there to hang it on. Fold Canada's divisions into the gazetteer the way we folded Europe's, and the postcodes I built light up at no added cost. Ship them on their own and all you've done is hand the resolver a stack of right answers to a question it can't ask.</p>
<p>So we didn't promote it. The map still doesn't have Canada — but it knows it doesn't now, and so do we. Build the test that grades the pin your user sees, and run it before you trust the green checkmarks upstream of it. They'll tell you the data is right. The pin tells you whether you are.</p>]]></content:encoded>
            <category>Geocoding</category>
            <category>Resolver / WOF</category>
            <category>Evaluation</category>
        </item>
        <item>
            <title><![CDATA[Geocoding that never phones home]]></title>
            <link>https://mailwoman.ai/research/geocoding-that-never-phones-home</link>
            <guid>https://mailwoman.ai/research/geocoding-that-never-phones-home</guid>
            <pubDate>Sat, 20 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Open the demo, open your browser's network tab, and type an address. It resolves to a rooftop coordinate: 1600 Pennsylvania Ave lands on the actual building, within about ten meters. The network tab shows no request carrying your address. The parser ran in the page. The gazetteer it resolved against is a file on a CDN, and the page read it a few kilobytes at a time. The query stayed on your machine.]]></description>
            <content:encoded><![CDATA[<p>Open the <a class="" href="https://mailwoman.ai/demo">demo</a>, open your browser's network tab, and type an address. It resolves to a rooftop coordinate: <code>1600 Pennsylvania Ave</code> lands on the actual building, within about ten meters. The network tab shows no request carrying your address. The parser ran in the page. The gazetteer it resolved against is a file on a CDN, and the page read it a few kilobytes at a time. The query stayed on your machine.</p>
<p>That is unusual, because each of the three kinds of product on the market today requires you to give something up.</p>
<!-- -->
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-three-options-and-what-each-one-costs-you">The three options, and what each one costs you<a href="https://mailwoman.ai/research/geocoding-that-never-phones-home#the-three-options-and-what-each-one-costs-you" class="hash-link" aria-label="Direct link to The three options, and what each one costs you" title="Direct link to The three options, and what each one costs you" translate="no">​</a></h2>
<p>The first option is a cloud API: Google, Mapbox, HERE Technologies, AWS, or Azure. You send the address over the network and get coordinates back. The results are fast and globally accurate, and depending on the vendor, they come with usage restrictions. Google's terms let you cache a coordinate for thirty days, after which you must delete it, so if you geocode the same ten thousand delivery addresses every morning, you pay for them every morning. AWS forbids caching autocomplete results and bills storage at a higher tier when you do cache. The address always leaves your infrastructure. That rules these services out when the address itself is sensitive, such as a shelter, a clinic, or a residence whose address is confidential.</p>
<p>The second option is to self-host: Nominatim, Pelias, and Photon are all mature, free software that you control. The license is fine. The cost is the infrastructure. Nominatim needs a Postgres/PostGIS install with more than a terabyte of disk and a planet import that takes days. Pelias is Elasticsearch plus five supporting services and a 450 GB index. Photon is OpenSearch with a 64 GB heap. You own your data, but you have to operate a search cluster to use it, and none of them can run in a browser.</p>
<p>The third option is a standalone parser. libpostal is the open address parser most projects embed, and it works well. It is also a 1.8 GB trained model (2.2 GB for the Senzing variant), written in C without a supported path to the browser, and it only parses. It tells you which token is the street, but it does not tell you where the street is.</p>
<p>The choices are to rent a service and send it the query, host a service and run a cluster, or parse the address and stop before geocoding. Mailwoman is a fourth option.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="what-if-it-ran-on-the-device">What if it ran on the device<a href="https://mailwoman.ai/research/geocoding-that-never-phones-home#what-if-it-ran-on-the-device" class="hash-link" aria-label="Direct link to What if it ran on the device" title="Direct link to What if it ran on the device" translate="no">​</a></h2>
<p>The whole pipeline, parsing and then resolving to a coordinate, runs client-side. The parser is a 29.8 MB int8 ONNX model executed by <code>onnxruntime-web</code>. The gazetteer is a single global SQLite file of around 530 MB covering every country we resolve against, and the page never downloads the whole file. It reads the file over HTTP range requests, pulling roughly a dozen small ranges to answer a query. The resolver needs no Elasticsearch, PostGIS, or native module. It uses plain <code>node:sqlite</code>, and the same code runs in Node and in a browser tab.</p>
<p>The resolver used to need 243 round trips. An index change reduced that count to twelve while the fetching stayed the same. <a class="" href="https://mailwoman.ai/research/243-round-trips-to-find-a-city">A separate post</a> covers that. The result is a global geocoder that answers queries with a few small reads and no server.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="a-neural-parser-the-size-of-a-photo">A neural parser the size of a photo<a href="https://mailwoman.ai/research/geocoding-that-never-phones-home#a-neural-parser-the-size-of-a-photo" class="hash-link" aria-label="Direct link to A neural parser the size of a photo" title="Direct link to A neural parser the size of a photo" translate="no">​</a></h2>
<p>The 29.8 MB model is roughly sixty times smaller than libpostal's default model and about seventy times smaller than the Senzing one. The smaller model is also the <em>better</em> parser on search-box input: typos, half-finished lines, and partial prefixes. A transformer trained on degraded queries reads <code>123 Penslyvania Av NW</code> the way you meant it. A CRF over hand-built features can miss those inputs. The model is small enough to ship to a browser and performs better on the input browsers produce.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="your-queries-never-leave-and-the-results-are-yours-forever">Your queries never leave, and the results are yours forever<a href="https://mailwoman.ai/research/geocoding-that-never-phones-home#your-queries-never-leave-and-the-results-are-yours-forever" class="hash-link" aria-label="Direct link to Your queries never leave, and the results are yours forever" title="Direct link to Your queries never leave, and the results are yours forever" translate="no">​</a></h2>
<p>This matters most to anyone holding addresses they cannot send to a third party. Without a server, there is no storage clause, zero data retained, zero deletion after thirty days, and zero leakage. You geocode once and keep the coordinate as long as you like. Healthcare and other workflows with confidential addresses can require on-device processing as a procurement condition.</p>
<p>This argument has limits. geocode.earth's hosted Pelias and Smarty both already offer generous data-ownership terms, so the argument applies mainly to vendors that require deletion: Google, AWS, and HERE Technologies. Against those vendors, local geocoding removes the deletion requirement.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-bill-is-zero">The bill is zero<a href="https://mailwoman.ai/research/geocoding-that-never-phones-home#the-bill-is-zero" class="hash-link" aria-label="Direct link to The bill is zero" title="Direct link to The bill is zero" translate="no">​</a></h2>
<p>Google's geocoder costs five dollars per thousand requests after the free tier. That is five thousand dollars a month at a million addresses, and six figures a year past ten million. On-device, a query has no marginal cost, because it runs on hardware the user already owns. A self-hosted geocoder also has no per-query cost, so this comparison applies to the cloud APIs, where on-device removes both the per-call charge <em>and</em> the server you would otherwise run.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="where-it-stops">Where it stops<a href="https://mailwoman.ai/research/geocoding-that-never-phones-home#where-it-stops" class="hash-link" aria-label="Direct link to Where it stops" title="Direct link to Where it stops" translate="no">​</a></h2>
<p>I would rather you hear the limits from me than discover them in production.</p>
<p>Rooftop precision (the actual building rather than the neighborhood) currently works only where we've shipped per-state address-point data: California, New York, Michigan, and DC. Everywhere else, the resolver returns an administrative or postcode centroid. That centroid gives the right town and ZIP but not the building. The large cloud vendors have much better global rooftop coverage. If you need a rooftop in Lagos today, use Google.</p>
<p>Mapbox does ship on-device geocoding, but only in their native mobile SDK, and it performs reverse geocoding (coordinate to address) rather than forward geocoding. The precise claim is that Mailwoman is the only geocoder that runs the full forward pipeline in a browser without a server. That is a narrow claim, and no other product we know of meets it. There is not yet a batch endpoint or an SLA. Mailwoman is infrastructure you embed, and it is not a hosted service with an on-call team.</p>
<p>In the network tab, you typed an address, got a building, and no other party saw the query.</p>
<div class="ss-code ss-code--shell language-bash codeBlockContainer_mQmQ theme-code-block" style="--prism-color:#f8f8f2;--prism-background-color:#272823"><span class="ss-code-lang">bash</span><div class="ss-code-content"><pre tabindex="0" class="prism-code language-bash codeBlock_RMoD thin-scrollbar" style="color:#f8f8f2;background-color:#272823"><code class="codeBlockLines_AclH"><div class="token-line" style="color:#f8f8f2"><span class="token plain">npm install mailwoman @mailwoman/neural</span><br></div></code></pre><button type="button" class="ss-code-copy" aria-label="Copy code to clipboard"><span aria-live="polite">Copy</span></button></div></div>
<p>Or open the <a class="" href="https://mailwoman.ai/demo">demo</a> and check the network tab yourself.</p>]]></content:encoded>
            <category>Infrastructure</category>
            <category>Geocoding</category>
            <category>Project motivation</category>
        </item>
        <item>
            <title><![CDATA[We keep the receipt on every coordinate]]></title>
            <link>https://mailwoman.ai/research/keep-the-receipt</link>
            <guid>https://mailwoman.ai/research/keep-the-receipt</guid>
            <pubDate>Sat, 20 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Every geocoder turns an address into a coordinate. Almost none of them will tell you where that coordinate came from. You get a latitude, a longitude, and a vague confidence enum, and when it's wrong you have no thread to pull — no way to know whether the point came from a federal data release, a county GIS office, or a straight line drawn down the middle of a street. Mailwoman keeps the source on every point. Here's New York, every dot colored by the open dataset it came from.]]></description>
            <content:encoded><![CDATA[<p>Every geocoder turns an address into a coordinate. Almost none of them will tell you where that coordinate came from. You get a latitude, a longitude, and a vague confidence enum, and when it's wrong you have no thread to pull — no way to know whether the point came from a federal data release, a county GIS office, or a straight line drawn down the middle of a street. Mailwoman keeps the source on every point. Here's New York, every dot colored by the open dataset it came from.</p>
<!-- -->
<p><img decoding="async" loading="lazy" alt="Address points across New York, each colored by its source dataset: green for the federal National Address Database statewide, orange for OpenAddresses (the city&amp;#39;s own NYC Open Data) concentrated in New York City." src="https://mailwoman.ai/assets/images/address-provenance-ny-6f96a9ab1ca055ca5c5831546efe503a.png" width="1300" height="898" class="img_SS3x"></p>
<p>Green is the National Address Database. Orange is OpenAddresses. For New York, OpenAddresses republishes the city's own NYC Open Data. You can <a href="https://mailwoman.ai/maps/address-provenance-ny.html" target="_blank" rel="noopener noreferrer" class="">explore the interactive version</a> and click any dot to see its publisher.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-split-is-geography-you-can-see">The split is geography you can see<a href="https://mailwoman.ai/research/keep-the-receipt#the-split-is-geography-you-can-see" class="hash-link" aria-label="Direct link to The split is geography you can see" title="Direct link to The split is geography you can see" translate="no">​</a></h2>
<p>The federal release blankets the state. New York City sits in its own color because the city publishes its own address file, and OpenAddresses republishes it. No guess was made about which is which. Every point kept its source, from ingest all the way to this map. The method is a <code>source</code> column that no pipeline stage dropped.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="credit-where-the-datas-due">Credit where the data's due<a href="https://mailwoman.ai/research/keep-the-receipt#credit-where-the-datas-due" class="hash-link" aria-label="Direct link to Credit where the data's due" title="Direct link to Credit where the data's due" translate="no">​</a></h2>
<p>None of this is our data. The National Address Database is a federal release. OpenAddresses is a decade of county and municipal publishers doing the unglamorous work of putting their address files online. The street network underneath is Census TIGER. They're free, they're open, and they're good. The lookup was never the hard part of geocoding. Each pipeline must assemble these sources, keep them fresh, and preserve their provenance. That record lets a user trace a wrong coordinate to the dataset and release that produced it.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-layers-under-the-dots">The layers under the dots<a href="https://mailwoman.ai/research/keep-the-receipt#the-layers-under-the-dots" class="hash-link" aria-label="Direct link to The layers under the dots" title="Direct link to The layers under the dots" translate="no">​</a></h2>
<p>This map shows the rooftop-point sources. Where there's no rooftop point, the geocoder falls back to TIGER street interpolation, a position estimated along the block, and below that to an administrative centroid from Who's On First. Each tier is a different open dataset, and every coordinate records which rung it landed on, so a rooftop in Manhattan and a block-interpolated guess in a thinly-covered county never get mistaken for each other.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="see-it-build-it">See it, build it<a href="https://mailwoman.ai/research/keep-the-receipt#see-it-build-it" class="hash-link" aria-label="Direct link to See it, build it" title="Direct link to See it, build it" translate="no">​</a></h2>
<p><a href="https://mailwoman.ai/maps/address-provenance-ny.html" target="_blank" rel="noopener noreferrer" class="">Explore the interactive map</a> — serve it over localhost, since the basemap tiles are CORS-scoped to the house domains. The provenance is a plain column on the address-point table:</p>
<div class="ss-code language-sql codeBlockContainer_mQmQ theme-code-block" style="--prism-color:#f8f8f2;--prism-background-color:#272823"><span class="ss-code-lang">sql</span><div class="ss-code-content"><pre tabindex="0" class="prism-code language-sql codeBlock_RMoD thin-scrollbar" style="color:#f8f8f2;background-color:#272823"><code class="codeBlockLines_AclH"><div class="token-line" style="color:#f8f8f2"><span class="token keyword" style="color:#66d9ef">SELECT</span><span class="token plain"> lat</span><span class="token punctuation" style="color:#f8f8f2">,</span><span class="token plain"> lon</span><span class="token punctuation" style="color:#f8f8f2">,</span><span class="token plain"> source </span><span class="token keyword" style="color:#66d9ef">FROM</span><span class="token plain"> address_point </span><span class="token keyword" style="color:#66d9ef">WHERE</span><span class="token plain"> locality_norm </span><span class="token operator" style="color:#66d9ef">=</span><span class="token plain"> </span><span class="token string" style="color:#a6e22e">'brooklyn'</span><span class="token plain"> </span><span class="token keyword" style="color:#66d9ef">LIMIT</span><span class="token plain"> </span><span class="token number" style="color:#ae81ff">1</span><span class="token punctuation" style="color:#f8f8f2">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain"></span><span class="token comment" style="color:#8292a2;font-style:italic">-- 40.6782 | -73.9442 | overture:OpenAddresses/NY/NYC Open Data</span><br></div></code></pre><button type="button" class="ss-code-copy" aria-label="Copy code to clipboard"><span aria-live="polite">Copy</span></button></div></div>
<p>Point the same map at any state:</p>
<div class="ss-code ss-code--shell language-bash codeBlockContainer_mQmQ theme-code-block" style="--prism-color:#f8f8f2;--prism-background-color:#272823"><span class="ss-code-lang">bash</span><div class="ss-code-content"><pre tabindex="0" class="prism-code language-bash codeBlock_RMoD thin-scrollbar" style="color:#f8f8f2;background-color:#272823"><code class="codeBlockLines_AclH"><div class="token-line" style="color:#f8f8f2"><span class="token plain">node \</span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain">  scripts/record-matcher/viz/source-provenance-map.ts --state ny</span><br></div></code></pre><button type="button" class="ss-code-copy" aria-label="Copy code to clipboard"><span aria-live="polite">Copy</span></button></div></div>]]></content:encoded>
            <category>Geocoding</category>
            <category>Project motivation</category>
        </item>
        <item>
            <title><![CDATA[The provider registry meets the Universal Service Fund]]></title>
            <link>https://mailwoman.ai/research/provider-registry-meets-usf</link>
            <guid>https://mailwoman.ai/research/provider-registry-meets-usf</guid>
            <pubDate>Sat, 20 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Three public datasets land on your desk. The national provider registry — NPPES, every NPI in the country. A federal telecom-funding file from the FCC's Rural Health Care program, one part of the Universal Service Fund. A state list of licensed nursing facilities from Texas HHSC. You want to know which records describe the same provider, and not one of the three shares an identifier with the other two. The NPI is internal to NPPES. The funding file keys on its own SPIN. The state list has its own facility ID. There's no crosswalk, because none of the three publishers ever built one.]]></description>
            <content:encoded><![CDATA[<p>Three public datasets land on your desk. The national provider registry — NPPES, every NPI in the country. A federal telecom-funding file from the FCC's Rural Health Care program, one part of the Universal Service Fund. A state list of licensed nursing facilities from Texas HHSC. You want to know which records describe the same provider, and not one of the three shares an identifier with the other two. The NPI is internal to NPPES. The funding file keys on its own SPIN. The state list has its own facility ID. There's no crosswalk, because none of the three publishers ever built one.</p>
<p>So you do what everyone does: you start a spreadsheet, you sort by name, and you give up around row 400.</p>
<!-- -->
<p>This map shows the result of resolving them anyway, scoped to Texas:</p>
<p><img decoding="async" loading="lazy" alt="Providers resolved across the NPPES registry, the FCC Rural Health Care funding file, and the Texas HHSC licensing list — on the geocoded place, with no shared key." src="https://mailwoman.ai/assets/images/provider-registry-usf-c4ce75c516be3e418ffad5a4ed25697a.png" width="2200" height="1520" class="img_SS3x"></p>
<p>Every dot is an entity that resolved across at least two of those datasets. Their identifiers didn't match, because there are none to match. They resolved because the records geocoded to the same place and their names agreed once you normalized them. The resolved location is the key the three publishers never agreed on.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="how-you-join-files-that-share-no-key">How you join files that share no key<a href="https://mailwoman.ai/research/provider-registry-meets-usf#how-you-join-files-that-share-no-key" class="hash-link" aria-label="Direct link to How you join files that share no key" title="Direct link to How you join files that share no key" translate="no">​</a></h2>
<p>The trick is to stop matching strings and start matching places. Each record's address goes through the neural parser, resolves to a coordinate and a place in the hierarchy, and gets blocked by <em>where it is</em>. Only then do the names come in, canonicalized — legal-form noise stripped, abbreviations expanded — and scored with label-free Fellegi-Sunter weights (<a class="" href="https://mailwoman.ai/research/same-building-different-company">the geocode-first record-matching post</a> walks through this). "Baylor Coll. of Medicine" and "Baylor College of Medicine" stop being different strings; "123 Main St" and "123 Main Street, Ste 400" stop being different places. The geocode does the blocking, the normalized name does the deciding, and neither file had to store the other's ID.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-numbers-and-exactly-what-they-are">The numbers, and exactly what they are<a href="https://mailwoman.ai/research/provider-registry-meets-usf#the-numbers-and-exactly-what-they-are" class="hash-link" aria-label="Direct link to The numbers, and exactly what they are" title="Direct link to The numbers, and exactly what they are" translate="no">​</a></h2>
<p>219 entities resolved across two or more of these sources. Twenty-eight of them span different <em>agencies</em> — CMS's provider registry, the FCC's funding program, Texas's licensing list — and those are the hard ones, because inside a single agency you can sometimes lean on an internal ID, while across agencies you have only the place and the name. (The other 191 links are FCC-internal: Rural Health Care filings matched back to their own commitment records.) You can see the cross-agency subset on its own in the <a href="https://mailwoman.ai/maps/provider-registry-usf-crossagency.html" target="_blank" rel="noopener noreferrer" class="">interactive map</a>. The <a href="https://mailwoman.ai/maps/provider-registry-usf.html" target="_blank" rel="noopener noreferrer" class="">full set is here</a>.</p>
<p>Then the coverage reconciliation. Of the 2,771 entities carrying an eligibility record — an NPPES organization or a TX HHSC facility — 22 also resolve to a funding record. That's 0.8%, and it's a floor rather than a coverage rate. Imperfect resolution and a capped sample only ever miss links. They never invent them. The set is the deliverable, and the percentage only reports its size.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="read-this-part-before-you-read-anything-into-it">Read this part before you read anything into it<a href="https://mailwoman.ai/research/provider-registry-meets-usf#read-this-part-before-you-read-anything-into-it" class="hash-link" aria-label="Direct link to Read this part before you read anything into it" title="Direct link to Read this part before you read anything into it" translate="no">​</a></h2>
<p>This is a set-membership reconciliation rather than a determination. The 2,749 eligibility entities that didn't resolve to a funding record are a candidate set for review, and no more. A missing funding link can mean the entity didn't apply, applied under a name the resolver didn't catch, isn't eligible, or only that its funding record fell outside this capped 2,000-row sample — a sampling artifact rather than a finding. At full scale the set tightens, and it's still only a set of candidates.</p>
<p>What we produce is the reconciled join and the candidate set. What a gap means, and whether it means anything at all, is the data consumer's call rather than the tool's. None of this is an allegation, and the map makes no claim about any provider on it beyond "these records appear to describe the same place."</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="run-it-on-your-own-files">Run it on your own files<a href="https://mailwoman.ai/research/provider-registry-meets-usf#run-it-on-your-own-files" class="hash-link" aria-label="Direct link to Run it on your own files" title="Direct link to Run it on your own files" translate="no">​</a></h2>
<p>The map above is the same <code>toMapHTML</code> the record matcher ships, on the house MapLibre and Protomaps stack. The whole thing is one command over a config that tags each file with a role:</p>
<div class="ss-code ss-code--shell language-bash codeBlockContainer_mQmQ theme-code-block" style="--prism-color:#f8f8f2;--prism-background-color:#272823"><span class="ss-code-lang">bash</span><div class="ss-code-content"><pre tabindex="0" class="prism-code language-bash codeBlock_RMoD thin-scrollbar" style="color:#f8f8f2;background-color:#272823"><code class="codeBlockLines_AclH"><div class="token-line" style="color:#f8f8f2"><span class="token plain">mailwoman registry --sources sources.json --reconcile --map-out map.html</span><br></div></code></pre><button type="button" class="ss-code-copy" aria-label="Copy code to clipboard"><span aria-live="polite">Copy</span></button></div></div>
<p>Point it at your own pile of registries. The resolved place is the key they forgot to share.</p>]]></content:encoded>
            <category>Record matching</category>
            <category>Geocoding</category>
        </item>
        <item>
            <title><![CDATA[Same building. Different company. Now what?]]></title>
            <link>https://mailwoman.ai/research/same-building-different-company</link>
            <guid>https://mailwoman.ai/research/same-building-different-company</guid>
            <pubDate>Sat, 20 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Suppose you have a set of records and no key to join them on. A clinic appears in the federal provider registry, again in a state licensing export, and a third time in a funding-program spreadsheet that someone typed by hand. The files share no identifier. The provider number is internal to one publisher, and the facility ID is internal to another. You must judge which records describe the same place for each pair, repeated a few million times.]]></description>
            <content:encoded><![CDATA[<p>Suppose you have a set of records and no key to join them on. A clinic appears in the federal provider registry, again in a state licensing export, and a third time in a funding-program spreadsheet that someone typed by hand. The files share no identifier. The provider number is internal to one publisher, and the facility ID is internal to another. You must judge <em>which records describe the same place</em> for each pair, repeated a few million times.</p>
<p>The market offers two kinds of tools for this. Each solves part of the problem, and neither solves all of it.</p>
<!-- -->
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-two-tools-and-the-halves-they-skip">The two tools, and the halves they skip<a href="https://mailwoman.ai/research/same-building-different-company#the-two-tools-and-the-halves-they-skip" class="hash-link" aria-label="Direct link to The two tools, and the halves they skip" title="Direct link to The two tools, and the halves they skip" translate="no">​</a></h2>
<p>The first tool is a string matcher, such as Splink, Zingg, dedupe, or the fuzzy-match component inside Tamr or Tilores. You give it name and address columns, and it scores how similar the text is. Used as the <em>whole</em> answer, it has a basic flaw: text similarity does not measure place similarity. An address two doors down the same street is one character away. The same building written two different ways can score far apart. Threshold tuning then takes weeks, and a threshold that is too loose in cities is too tight in suburbs. (We covered this in detail in <a class="" href="https://mailwoman.ai/research/match-where-it-is-not-how-its-spelled">Match where it is rather than how it's spelled</a>. In short, <code>Jyllandsgade 15</code> and <code>Jyllandsgade 75</code> score 0.96 similar and sit 650 meters apart, and no threshold handles that.)</p>
<p>Strings are still useful. Names are some of the best evidence available, and later sections argue for relying on them heavily. The limitation is narrower: a string describes how a record is spelled and makes no statement about where it is, so it should not be the field you <em>block</em> on.</p>
<p>The second tool is a join key, such as Placekey, SmartyKey, or our own <code>@mailwoman/address-id</code>. It normalizes the address, geocodes it, and returns a deterministic identifier. Records with the same key are at the same place. This is better than string matching, because it keys on <em>where</em> instead of on <em>spelling</em>. Placekey will tell you that two records are in the same building.</p>
<p>The key does not go further than that, and matching the building solves only the easy eighty percent.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="a-key-gets-you-to-the-doormat">A key gets you to the doormat<a href="https://mailwoman.ai/research/same-building-different-company#a-key-gets-you-to-the-doormat" class="hash-link" aria-label="Direct link to A key gets you to the doormat" title="Direct link to A key gets you to the doormat" translate="no">​</a></h2>
<p>Key vendors say little about the remaining cases. At <code>1504 Taub Loop</code> in Houston, four separate provider registrations resolve to the same rooftop, and all four are Baylor College of Medicine, one organization registered four times. A deterministic key groups them together, and that grouping is correct. At the next address, a single medical-office building holds a dozen distinct practices with different owners and specialties that share only the street address. The key groups <em>them</em> together too, and that grouping is wrong. The key cannot distinguish the two cases, because it encodes only location.</p>
<p>Placekey's own FAQ states that it "operates as a universal join key rather than a matching service… it doesn't perform record linkage itself." That is an accurate description of what a key does. The key finds records in the same building. The remaining question is whether records in the same building, with different company names, are one entity or two.</p>
<p>A probability answers that question.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-missing-half-a-number-you-can-read-and-the-humility-to-abstain">The missing half: a number you can read, and the humility to abstain<a href="https://mailwoman.ai/research/same-building-different-company#the-missing-half-a-number-you-can-read-and-the-humility-to-abstain" class="hash-link" aria-label="Direct link to The missing half: a number you can read, and the humility to abstain" title="Direct link to The missing half: a number you can read, and the humility to abstain" translate="no">​</a></h2>
<p>Mailwoman is built around this second step. It first resolves every record to a coordinate and a place hierarchy and blocks on <em>where</em>. It then runs a probabilistic scorer over the candidates that share a place. The scorer is Fellegi-Sunter. Every field agreement or disagreement contributes a weight in bits, and the sum gives a calibrated probability that two records are the same entity, with the evidence shown so you can see <em>why</em>. A shared rare street counts for more than a shared common one. A shared rooftop counts for more than a shared interpolated point, because the geocoder reports its confidence to the matcher. The score also includes the string comparison: Jaro-Winkler over the names, where agreement on a rare organization-name token counts heavily and a shared <code>LLC</code> counts for very little. Names still matter, but they now decide <em>who</em> and no longer decide <em>where</em>. That role suits them better. When the probability falls in the uncertain middle, the matcher <strong>abstains</strong> and routes the pair to a human reviewer.</p>
<p>In summary: <em>Placekey tells you two records are in the same building. Mailwoman tells you, with a calibrated probability and a readable reason, whether they're the same company, and flags the pair for review when it should not guess.</em> That covers the linking step the key vendors omit and the geography the string-only matchers omit, and it keeps name matching in the model.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="when-the-string-is-the-answer-normalize-it-first">When the string is the answer, normalize it first<a href="https://mailwoman.ai/research/same-building-different-company#when-the-string-is-the-answer-normalize-it-first" class="hash-link" aria-label="Direct link to When the string is the answer, normalize it first" title="Direct link to When the string is the answer, normalize it first" translate="no">​</a></h2>
<p>Sometimes the name decides the match. A comparison of the federal provider registry with the FCC's funding records shows one such case. The same health system appears in one file as <code>BAYLOR COLLEGE OF MEDICINE</code> and in the other as <code>Baylor Coll. of Medicine</code>, as a d/b/a, or with a <code>, LLC</code> that the other publisher dropped. Geography places the two records in the same area, so they are plausibly the same place. The name decides whether they are the <em>same entity</em>, provided it is normalized before comparison.</p>
<p>Normalization therefore runs first. <code>canonicalizeOrganizationName</code> strips legal-form terms (<code>LLC</code>, <code>Inc</code>, a leading <code>The</code>), splits the d/b/a, normalizes punctuation, and uses the ISO 20275 entity-form list so that <code>Coll</code> and <code>College</code> compare as the same word. <em>Then</em> the names are compared, and agreement on the rare token <code>Baylor</code> counts for much more than a shared <code>Medical Center</code>. The same mechanism handles both directions. The building with a dozen distinct practices stays a dozen entities, because they share a coordinate but have different normalized names. One facility registered twice under different operational names merges into one. The coordinate does the blocking, and the normalized name comparison decides the match. Neither step by itself reconciles NPPES and the FCC data. Together they do.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-matcher-does-not-need-labels">The matcher does not need labels<a href="https://mailwoman.ai/research/same-building-different-company#the-matcher-does-not-need-labels" class="hash-link" aria-label="Direct link to The matcher does not need labels" title="Direct link to The matcher does not need labels" translate="no">​</a></h2>
<p>Most ML-based matchers also assume you have training data. Zingg asks you to label thirty to fifty pairs to start, dedupe runs an active-learning loop built on labeling, and Tamr expects ongoing human curation. The input may consist of CSV files and a deadline, without labeled pairs.</p>
<p>Mailwoman's scorer estimates its parameters from the data without labels, using expectation-maximization. Splink uses the same label-free Fellegi-Sunter method, and it runs at a scale we have not matched. The difference is that Splink expects <em>you</em> to have already geocoded the addresses, while Mailwoman covers the whole stack from raw string to resolved place to calibrated link. When you <em>do</em> have labels, an optional gradient-boosted scorer captures non-linear interactions that the hand-built weights miss. In our held-out tests it added about six points of pairwise F1 and five of clustering F1, across every random seed we tried.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="it-travels">It travels<a href="https://mailwoman.ai/research/same-building-different-company#it-travels" class="hash-link" aria-label="Direct link to It travels" title="Direct link to It travels" translate="no">​</a></h2>
<p>A learned scorer may memorize one dataset's quirks. To test that, we trained the boosted scorer on Texas providers and evaluated it on held-out states it had never seen. It beat the unsupervised baseline by 20.5 points of F1 on California and 21.9 on New York. The pattern it learns, what over-merging looks like, transfers beyond Texas.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="show-your-ruler">Show your ruler<a href="https://mailwoman.ai/research/same-building-different-company#show-your-ruler" class="hash-link" aria-label="Direct link to Show your ruler" title="Direct link to Show your ruler" translate="no">​</a></h2>
<p>A result such as "F1 of 68%" is meaningless until you state <em>what it was measured against</em>. The same clusters score from 53.6% to 68.1% against four different definitions of truth. The clusters did not change. Only the ground truth changed, as it defined one entity more precisely. We hand-adjudicated 120 of the hardest Texas pairs (co-located, with similar names and distinct IDs). All 120 were the same real-world organization under different registrations, and none were unrelated companies merged by mistake. We report the ground truth next to the number. Most vendors do not.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="own-it-and-what-it-doesnt-do">Own it, and what it doesn't do<a href="https://mailwoman.ai/research/same-building-different-company#own-it-and-what-it-doesnt-do" class="hash-link" aria-label="Direct link to Own it, and what it doesn't do" title="Direct link to Own it, and what it doesn't do" translate="no">​</a></h2>
<p>It runs on your machine. It is open source, runs on your infrastructure, and has no per-record charge. The alternatives include AWS at $0.25 per thousand records (billed on non-matches as well), Senzing starting around $58k a year, and Tamr, Quantexa, and Tilores. Those three all require a quote. This can meet data-control requirements in compliance and healthcare workflows.</p>
<p>The claims above depend on stating these limits. Placekey <em>does</em> implement place-identity matching. Angle four is our response, but the overlap is real. Melissa ships patented coordinate-proximity matching, so it would be false to say no one else uses coordinates. Our narrower claim is that we make the <em>calibrated</em> coordinate the primary evidence in a probabilistic model. Splink offers the label-free baseline at much larger scale. The enterprise incumbents lead on raw volume, multi-domain mastering, support, and decades of name-matching expertise. None of them combines an open, label-free, geocode-first approach with the geocoder and the linker in one auditable stack. That combination is what distinguishes Mailwoman.</p>
<p>Every match the matcher surfaces is a candidate for review, and the final decision is yours. You decide what a correlation means and whether it means anything.</p>
<div class="ss-code ss-code--shell language-bash codeBlockContainer_mQmQ theme-code-block" style="--prism-color:#f8f8f2;--prism-background-color:#272823"><span class="ss-code-lang">bash</span><div class="ss-code-content"><pre tabindex="0" class="prism-code language-bash codeBlock_RMoD thin-scrollbar" style="color:#f8f8f2;background-color:#272823"><code class="codeBlockLines_AclH"><div class="token-line" style="color:#f8f8f2"><span class="token plain">npm install @mailwoman/registry</span><br></div></code></pre><button type="button" class="ss-code-copy" aria-label="Copy code to clipboard"><span aria-live="polite">Copy</span></button></div></div>
<div class="ss-code language-ts codeBlockContainer_mQmQ theme-code-block" style="--prism-color:#f8f8f2;--prism-background-color:#272823"><span class="ss-code-lang">ts</span><div class="ss-code-content"><pre tabindex="0" class="prism-code language-ts codeBlock_RMoD thin-scrollbar" style="color:#f8f8f2;background-color:#272823"><code class="codeBlockLines_AclH"><div class="token-line" style="color:#f8f8f2"><span class="token keyword" style="color:#66d9ef">import</span><span class="token plain"> </span><span class="token punctuation" style="color:#f8f8f2">{</span><span class="token plain"> resolveEntities</span><span class="token punctuation" style="color:#f8f8f2">,</span><span class="token plain"> toMapHTML </span><span class="token punctuation" style="color:#f8f8f2">}</span><span class="token plain"> </span><span class="token keyword" style="color:#66d9ef">from</span><span class="token plain"> </span><span class="token string" style="color:#a6e22e">"@mailwoman/registry"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain"></span><span class="token keyword" style="color:#66d9ef">const</span><span class="token plain"> entities </span><span class="token operator" style="color:#66d9ef">=</span><span class="token plain"> </span><span class="token keyword" style="color:#66d9ef">await</span><span class="token plain"> </span><span class="token function" style="color:#e6db74">resolveEntities</span><span class="token punctuation" style="color:#f8f8f2">(</span><span class="token plain">records</span><span class="token punctuation" style="color:#f8f8f2">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#f8f8f2">{</span><span class="token comment" style="color:#8292a2;font-style:italic">/* sensible defaults */</span><span class="token punctuation" style="color:#f8f8f2">}</span><span class="token punctuation" style="color:#f8f8f2">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain"></span><span class="token comment" style="color:#8292a2;font-style:italic">// → clustered entities, each with a coordinate, a cohesion score, and the records it merged</span><br></div></code></pre><button type="button" class="ss-code-copy" aria-label="Copy code to clipboard"><span aria-live="polite">Copy</span></button></div></div>
<p><code>resolveEntities</code> runs the whole block → score → cluster pipeline; <code>toMapHTML</code> drops the result on a map so you can see, dataset by dataset, what linked to what.</p>]]></content:encoded>
            <category>Record matching</category>
            <category>Geocoding</category>
            <category>Project motivation</category>
        </item>
        <item>
            <title><![CDATA[Three times this week, our metrics undersold us]]></title>
            <link>https://mailwoman.ai/research/2026/06/19/three-times-our-metrics-undersold-us</link>
            <guid>https://mailwoman.ai/research/2026/06/19/three-times-our-metrics-undersold-us</guid>
            <pubDate>Fri, 19 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[We're trained to be suspicious of a score that looks too good. The scores that say you failed get a free pass — and this week three of them were lying, including one that had us reporting kilometers for a product that lands in meters.]]></description>
            <content:encoded><![CDATA[<p>We spend effort distrusting numbers that look too good. A validation score that jumps, an accuracy that rounds up suspiciously close to 100 — we've been burned by those, so we poke at them. The number that reports you <em>failed</em> gets a free pass. We assume it's right, because who lies to make themselves look bad?</p>
<p>Our evals did, three times this week. One of them nearly talked us out of a model we should ship. One invented a coverage problem we don't have. And one had us writing "3.3 km" into a model card for a geocoder that puts most addresses within a hundred meters. Each time the fix was the same, and embarrassingly cheap: stop reading the summary row and pull the actual records the summary is averaging over.</p>
<!-- -->
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="one-bad-row-out-of-twenty-seven">One bad row out of twenty-seven<a href="https://mailwoman.ai/research/2026/06/19/three-times-our-metrics-undersold-us#one-bad-row-out-of-twenty-seven" class="hash-link" aria-label="Direct link to One bad row out of twenty-seven" title="Direct link to One bad row out of twenty-seven" translate="no">​</a></h2>
<p>We were grading a candidate model against a country-homograph set — addresses where the country name is also a US town, like "Lima, Peru" sitting next to "Lima, Ohio." The candidate scored 80.9 where the shipped model scored 83.3. A 2.4-point country regression, right there in the table. That's the kind of number that holds up a promote.</p>
<p>So we dumped the rows and diffed them, model against model. The entire 2.4 points was <em>one address</em>: <code>Avenida Arequipa, Lima 15046, Peru</code>. The candidate kept the street, the locality, and the postcode identical — it dropped the trailing "Peru" entirely. Every other country in the same sentence shape resolved fine. Swap "Peru" for Chile, Bolivia, France, Jordan and the candidate gets them all. The set only has twenty-seven rows with a gold country, so a single flip moves the score by 3.7 points.</p>
<p>A metric that one record can swing by three and a half points isn't measuring a property of the model. It's measuring that record. The "regression" was a rumor, and we'd have let it sway a decision that mattered if we hadn't read the row.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-towns-the-scorer-threw-away">The towns the scorer threw away<a href="https://mailwoman.ai/research/2026/06/19/three-times-our-metrics-undersold-us#the-towns-the-scorer-threw-away" class="hash-link" aria-label="Direct link to The towns the scorer threw away" title="Direct link to The towns the scorer threw away" translate="no">​</a></h2>
<p>The same week, the same eval told us our rural US locality resolution was a disaster: South Dakota matching 62%, Vermont 31%. That fit a story we already believed — the gazetteer is thin in rural states, the model can't resolve what isn't there — so it was easy to nod and file it under "known coverage gap."</p>
<p>Then we pulled the misses. The resolver was landing the right place on nearly all of them. The problem was the scorer: it only counted a match if the resolved place was tagged <code>locality</code>, and New England-style civil towns are <code>localadmin</code> in our gazetteer rather than <code>locality</code>. "Barre Town," "Saint Albans Town," every Vermont township — the resolver found them, and the metric threw them on the floor for having the wrong placetype label. Credit the group the resolver treats as a locality and Vermont goes from 31% to 93%, South Dakota from 62% to 98%. The model was fine. The data was fine. The ruler was bent.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="kilometers-on-paper-meters-in-production">Kilometers on paper, meters in production<a href="https://mailwoman.ai/research/2026/06/19/three-times-our-metrics-undersold-us#kilometers-on-paper-meters-in-production" class="hash-link" aria-label="Direct link to Kilometers on paper, meters in production" title="Direct link to Kilometers on paper, meters in production" translate="no">​</a></h2>
<p>This is the one that stings. Our flagship accuracy number — the one in the docs, the one in the model card, the one behind every "the US bottleneck is rural coverage" conversation we'd had for weeks — was coordinate error p50 3.3 km, p90 10 km. We read that as the ceiling. A city centroid is legitimately a few kilometers from an edge address, so we'd long since made our peace with it and gone looking for the next gazetteer to ingest.</p>
<p>The eval was resolving to the admin centroid. That's it. It took the parsed address, found the city, and returned the city's center point. But that is <em>not what the geocoder ships.</em> Production runs a cascade — it reads the parsed street, pulls that state's rooftop and interpolation data, and resolves the actual point, only falling back to the centroid when no point data exists. The eval never wired that layer in, so it had been reporting the blunt fallback as if it were the product.</p>
<p>We pointed the eval at the real cascade and ran the same ten thousand addresses. p50 went from 3.3 km to <strong>zero</strong>. p90 from 10 km to 1 km. <strong>85.9% of US addresses land within a hundred meters, and 90% within a kilometer</strong> — 80% on an exact rooftop point, another 8% on a street interpolation, and only the remaining 12% falling back to the centroid we'd been quoting as the headline. We had been underselling our own geocoder by three orders of magnitude, in public, because the eval graded a path the product doesn't take. And the "rural coordinate bottleneck" we'd been planning campaigns around? Same bent ruler. The rural states land on rooftops like everywhere else.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="read-the-rows">Read the rows<a href="https://mailwoman.ai/research/2026/06/19/three-times-our-metrics-undersold-us#read-the-rows" class="hash-link" aria-label="Direct link to Read the rows" title="Direct link to Read the rows" translate="no">​</a></h2>
<p>Three different failures, one fix. A country regression that was a single record. A coverage gap that was a placetype-tag mismatch. A flagship accuracy number that was grading the wrong object entirely. None of them survived contact with the actual rows, and all of them looked authoritative as a number in a table.</p>
<p>There's a tidy version of this lesson — <em>grade the thing your user receives</em> — and it's true, and we keep it on the wall. But the operational habit underneath it is blunter than that. Before a number changes a decision, dump the records it's summarizing and read them. Ask whether there's a pattern or whether it's one row on a small denominator. Ask whether the thing you scored is the thing you ship. It costs ten minutes and a <code>--errors-json</code> flag, and it would have caught all three of these before they reached a card or a roadmap.</p>
<p>The averages lie in both directions. <a class="" href="https://mailwoman.ai/research/2026/06/18/the-macro-went-up">Yesterday it flattered us</a> — a macro-F1 climbed while the model got worse at the most common address there is. This week it sandbagged us, three times, and we almost believed it because a number that reports you're losing feels like honesty. It isn't honesty or dishonesty. It's an abstraction, and abstractions drop exactly the detail you needed. The rows still have it. Go read the rows.</p>]]></content:encoded>
            <category>Neural classifier</category>
            <category>Evaluation</category>
            <category>Model training</category>
            <category>Corpus pipeline</category>
        </item>
        <item>
            <title><![CDATA[The macro-F1 went up. Did the model get better?]]></title>
            <link>https://mailwoman.ai/research/2026/06/18/the-macro-went-up</link>
            <guid>https://mailwoman.ai/research/2026/06/18/the-macro-went-up</guid>
            <pubDate>Thu, 18 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[We taught our parser the hardest street cases it gets wrong, the aggregate score climbed, and we almost shipped it. Then a held-out probe caught it forgetting what a town is.]]></description>
            <content:encoded><![CDATA[<p>Here's a number that should make you nervous: our validation macro-F1 climbed from 0.71 to 0.73 on a retrain we were ready to ship. Instinct reads that as a win: the aggregate went up, so the model must be better, so publish the release.</p>
<p>It wasn't, and it took three probes to prove it. The same model that scored higher on the average had gotten <strong>worse at the most basic address there is — a town and a state.</strong> This post describes how the average lied to us, and how we caught it.</p>
<!-- -->
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="what-we-were-trying-to-fix">What we were trying to fix<a href="https://mailwoman.ai/research/2026/06/18/the-macro-went-up#what-we-were-trying-to-fix" class="hash-link" aria-label="Direct link to What we were trying to fix" title="Direct link to What we were trying to fix" translate="no">​</a></h2>
<p>Mailwoman's biggest measured weakness is <em>boundary instability</em>: the model wobbling on where one part of an address ends and the next begins. A street suffix swallowed into the street name. A French house number that trails the street instead of leading it. The kind of thing that's invisible on a clean "123 Main St, Springfield, IL 62701" and falls apart the moment the punctuation goes missing.</p>
<p>So we built a synthetic training extract that puts the gold boundary on exactly those hard cases, mixed it in at a conservative weight, and retrained. It worked — on the boundaries. French street-prefix recognition went from a coin flip (55%) to essentially perfect (99.7%). Every boundary shape moved up. The macro-F1 rose. We had the release notes half-written.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-catch">The catch<a href="https://mailwoman.ai/research/2026/06/18/the-macro-went-up#the-catch" class="hash-link" aria-label="Direct link to The catch" title="Direct link to The catch" translate="no">​</a></h2>
<p>We don't ship on the macro. We ship on a battery of per-locale floors ("don't regress US locality below 72.9," that sort of thing), graded against the <em>assembled</em> output: the actual parse a user would get rather than the per-token score the trainer optimizes.</p>
<p>One floor came back red. <strong>US locality: 66.2, floor 72.9.</strong> The retrain that aced the boundaries had dropped six and a half points on locality, on real held-out US addresses, while the aggregate went <em>up</em>. The gains were concentrated where we'd added training data. The loss was spread across everything else, and the average averaged it away.</p>
<p>This is the trap we've been burned by before, and it has a name on this project: <strong>grade the assembled coordinate, never the label-F1.</strong> We wrote it on the wall. We still almost walked past it.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="three-probes-one-culprit">Three probes, one culprit<a href="https://mailwoman.ai/research/2026/06/18/the-macro-went-up#three-probes-one-culprit" class="hash-link" aria-label="Direct link to Three probes, one culprit" title="Direct link to Three probes, one culprit" translate="no">​</a></h2>
<p>A failing floor tells you <em>that</em> something regressed rather than <em>what</em>. So we wrote probes.</p>
<p>The first asked a simple question of every boundary the model got wrong: is it <em>confidently</em> wrong, or is it <em>uncertain</em>? If the model spreads its probability mass and shrugs, that's a capacity ceiling — you need a bigger model, and more data is wasted GPU. If it commits hard to the wrong answer, that's a signal problem — more of the right data can flip it. Every failure came back confident. Good: this is fixable with data.</p>
<p>The second probe went after the locality drop directly. It took the model <em>before</em> the retrain and the model <em>after</em>, ran both over 1,616 real US addresses, and isolated every row where locality went from right to wrong. Then it asked where the locality text <em>went</em>.</p>
<p>We expected the street to be eating it. It wasn't. That was 3% of the damage. <strong>84% of the regression was the model dropping the locality entirely</strong> on short, bare rows: "LaMoure, ND." "Frannie, Wyoming." "Buckhorn, Wyoming." Each row held only a town and a state, and the model now returned <em>no locality tag</em> for the town.</p>
<p>The mechanism, once you see it, is almost funny. Every example in our hard-case extract had a street before the city — that's what made the boundary hard. So the model learned, a little too well, that <em>a city is the thing that comes after a street.</em> Take the street away, hand it a bare "LaMoure, ND," and it no longer recognized LaMoure as a place at all. We didn't teach it a new skill so much as we taught it a superstition.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-lesson-we-keep-re-learning">The lesson we keep re-learning<a href="https://mailwoman.ai/research/2026/06/18/the-macro-went-up#the-lesson-we-keep-re-learning" class="hash-link" aria-label="Direct link to The lesson we keep re-learning" title="Direct link to The lesson we keep re-learning" translate="no">​</a></h2>
<p>The boundary extract's labels were sound. The trouble was that it only ever showed them in one shape. Every row was a clean, full, structured address, so the model over-fit that shape and fell off the moment the input stopped matching it. The fix is to surround the hard cases with the ordinary ones they need to be told apart from. Teach the bare "City, STATE" next to the full address. Teach the house number before the street as well as after, so position stops being a shortcut the model can lean on.</p>
<p>But the deeper lesson is the one about the number we started with. The macro-F1 went up. The model got worse at its most common real-world input. Both of those things were true at the same time, and only one of them mattered. If you're tuning a model on an aggregate score, the aggregate is the first thing that will lie to you — and it will do it while pointing at a bigger number and smiling. Grade the thing your user receives. We had to be told twice.</p>
<p>Two separate things went wrong with that number, and fixing one wouldn't have caught the other. The first is the averaging. A gain on rare shapes and a small loss on the common one net out positive, so the loss disappears into the mean. The fix is to stop reading the mean and start reading each stratum on its own, US locality by itself rather than folded into a global score. The second is the one that nearly shipped the model. We were grading the wrong object: the trainer scores a label on every word, but the user receives an assembled parse, and the two can move in opposite directions. You can label more words correctly and still hand back a worse address, because one wrong word in the wrong place reshuffles the structure around it. Stratify the word-labels as finely as you like and you're still grading the wrong product. So we grade the finished parse against what a real user would hand us, and we read it stratum by stratum. Both halves, every time.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="coda-did-the-fix-work">Coda: did the fix work?<a href="https://mailwoman.ai/research/2026/06/18/the-macro-went-up#coda-did-the-fix-work" class="hash-link" aria-label="Direct link to Coda: did the fix work?" title="Direct link to Coda: did the fix work?" translate="no">​</a></h2>
<p>Yes, on the labels — and in a way that drove the lesson home twice. We built exactly what the probe pointed at: teach the model bare "City, STATE" rows, so it stops needing a street to recognize a town. Held-out locality F1 climbed from 66 back up to 80, past where it started before any of this. The macro-F1? It barely twitched.</p>
<p>That's the whole point, restated from the other direction. The aggregate that almost fooled us into shipping the broken model was the same aggregate that wouldn't have told us the fix landed, either. Both times, the truth was in the held-out core (the town, the street, the house number a real user hands us), and both times the averageshrugged. So we read the core.</p>
<p>And then the lesson came back for a third helping. Before promoting the new model we graded the one thing we hadn't: the assembled coordinate, the actual point on the map a user receives, measured against the model already in production. It was flat. Locality F1 had climbed, but locality-match on real addresses and the median coordinate error did not move. the result never reached the map. We had done the very thing this post opens by warning against, one level down: graded the assembled labels and called it a win without grading the assembled coordinate. So we left the production model where it was.</p>
<p>The flatness pointed somewhere we hadn't been looking. The addresses still wrong on the map are small rural towns the gazetteer itself resolves poorly (a third of Vermont's, in one sample), places no amount of model training reaches. For now the model has caught up to its own database, and the result is in the atlas. That's a different post, and we'll grade it on the coordinate.</p>
<p>One footnote, because "add more data" and "train a bigger model" aren't the only two endings a stuck case has. When a change refuses to move, there are three reasons, and they ask for different things. Maybe the model picked up a bad habit from the data. Better data breaks that habit, as the sections above show. Maybe it's a thirty-million-parameter model out of room. Then only a bigger one helps. Or maybe the answer was never in the text: "Paris," on its own, is Paris, France or Paris, Texas, and no amount of data or parameters settles that, because the string doesn't contain the answer. The cheap way to tell the second reason from the third is to ask whether a person could do it from the very same input. Our four stubborn boundary cases are the second kind. A person segments a comma-less "City STATE" without trouble, so that's capacity rather than confusion. We banked the result and left the boundaries for a bigger model. The third kind was never the model's job in the first place. It tags "Paris" as a place, and the gazetteer decides which Paris, leaning on the rest of the address and, when there's no other evidence to go on, on which one more people mean. A team can choose the right fix only after it distinguishes these three causes. That's a different post.</p>]]></content:encoded>
            <category>Neural classifier</category>
            <category>Evaluation</category>
            <category>Model training</category>
            <category>Corpus pipeline</category>
        </item>
        <item>
            <title><![CDATA[A tie on Main Street, a rout at the PO Box]]></title>
            <link>https://mailwoman.ai/research/a-tie-on-main-street</link>
            <guid>https://mailwoman.ai/research/a-tie-on-main-street</guid>
            <pubDate>Wed, 17 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Mailwoman ships two address parsers in the same box.]]></description>
            <content:encoded><![CDATA[<p>Mailwoman ships two address parsers in the same box.</p>
<p>We call the first one <strong>v0</strong>. It is our TypeScript port of the <a href="https://github.com/pelias/parser" target="_blank" rel="noopener noreferrer" class="">Pelias parser</a> — a rules engine: tokenize, classify each token against dictionaries and patterns, solve for the most plausible arrangement under a pile of hand-written constraints. It is fast, deterministic, and the product of years of accumulated postal wisdom. It is also the thing we set out to beat.</p>
<p>The second is the <strong>neural</strong> classifier — a sequence labeler trained on a BIO-tagged corpus, the one this blog has spent most of its life arguing with.</p>
<p>So: a year in, did the neural net beat the rules parser? The answer is <em>mostly a tie, until the address gets weird</em> — and the weird is where it gets interesting.</p>
<!-- -->
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-scoreboard">The scoreboard<a href="https://mailwoman.ai/research/a-tie-on-main-street#the-scoreboard" class="hash-link" aria-label="Direct link to The scoreboard" title="Direct link to The scoreboard" translate="no">​</a></h2>
<p>We don't grade the two parsers against each other's labels — thatmeasures which one looks more like the other. We grade both through the <strong>same resolver</strong>, against real address points: take 2,000 <a href="https://openaddresses.io/" target="_blank" rel="noopener noreferrer" class="">OpenAddresses</a> US rows the model never trained on, parse each one, resolve it to a coordinate, and measure how frequently we land in the right city, the right state, and how far the resolved point is from the truth.</p>
<table><thead><tr><th>parser</th><th style="text-align:right">locality match</th><th style="text-align:right">region match</th><th style="text-align:right">coord p50</th><th style="text-align:right">coord p90</th><th style="text-align:right">coord p99</th></tr></thead><tbody><tr><td><strong>neural</strong></td><td style="text-align:right"><strong>84.0%</strong></td><td style="text-align:right"><strong>99.9%</strong></td><td style="text-align:right">3.3 km</td><td style="text-align:right"><strong>10.7 km</strong></td><td style="text-align:right"><strong>239 km</strong></td></tr><tr><td>v0 (Pelias port)</td><td style="text-align:right">82.1%</td><td style="text-align:right">99.5%</td><td style="text-align:right">3.3 km</td><td style="text-align:right">11.2 km</td><td style="text-align:right">277 km</td></tr></tbody></table>
<p>On clean US addresses it is close. Neural is a couple of points ahead on locality, a hair ahead on region, dead even at the median coordinate — and the difference you can feel is in the <strong>tail</strong>: neural's p99 error is 239 km versus 277. Both parsers nail the easy ones. The neural net throws fewer wild misses.</p>
<p>(Pelias publishes no accuracy numbers of its own — only architecture docs — which is exactly why v0 <em>is</em> our baseline. We can't compare to a number the Pelias project never printed, so we ported the thing and graded it ourselves.)</p>
<p>A couple of points on clean addresses is not the headline. The headline is what happens off the beaten path.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="where-the-neural-net-pulls-ahead">Where the neural net pulls ahead<a href="https://mailwoman.ai/research/a-tie-on-main-street#where-the-neural-net-pulls-ahead" class="hash-link" aria-label="Direct link to Where the neural net pulls ahead" title="Direct link to Where the neural net pulls ahead" translate="no">​</a></h2>
<p>Here is the same handful of addresses through both parsers. Watch the rules engine on anything that isn't <code>&lt;number&gt; &lt;street&gt;, &lt;city&gt;, &lt;state&gt; &lt;zip&gt;</code>.</p>
<p><strong>A PO box</strong> — the classic. <code>PO Box 1207, Anchorage, AK 99510</code>:</p>
<div class="ss-code language-text codeBlockContainer_mQmQ theme-code-block" style="--prism-color:#f8f8f2;--prism-background-color:#272823"><span class="ss-code-lang">text</span><div class="ss-code-content"><pre tabindex="0" class="prism-code language-text codeBlock_RMoD thin-scrollbar" style="color:#f8f8f2;background-color:#272823"><code class="codeBlockLines_AclH"><div class="token-line" style="color:#f8f8f2"><span class="token plain">v0    : street="PO Box"  house_number=1207  locality=Anchorage  region=AK  postcode=99510</span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain">neural: po_box="PO Box 1207"  locality=Anchorage  region=AK  postcode=99510</span><br></div></code></pre><button type="button" class="ss-code-copy" aria-label="Copy code to clipboard"><span aria-live="polite">Copy</span></button></div></div>
<p>The rules parser reads "PO Box 1207" as a street called "PO Box" with house number 1207. It's not wrong by its own logic — "Box" looks like a street type, a number follows it — it has no concept that this is a <em>post office box</em>, a different kind of place entirely. The neural net learned the <code>po_box</code> tag and uses it.</p>
<p><strong>An intersection</strong> — <code>5th &amp; Main, Springfield, IL</code>:</p>
<div class="ss-code language-text codeBlockContainer_mQmQ theme-code-block" style="--prism-color:#f8f8f2;--prism-background-color:#272823"><span class="ss-code-lang">text</span><div class="ss-code-content"><pre tabindex="0" class="prism-code language-text codeBlock_RMoD thin-scrollbar" style="color:#f8f8f2;background-color:#272823"><code class="codeBlockLines_AclH"><div class="token-line" style="color:#f8f8f2"><span class="token plain">v0    : street="5th"  locality=Springfield  region=IL</span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain">neural: intersection_a="5th"  intersection_b="Main"  locality=Springfield  region=IL</span><br></div></code></pre><button type="button" class="ss-code-copy" aria-label="Copy code to clipboard"><span aria-live="polite">Copy</span></button></div></div>
<p>v0 keeps the first street and <strong>drops "Main" on the floor</strong>. The neural net knows an intersection has two sides and labels both.</p>
<p><strong>A unit</strong> — <code>1600 Pennsylvania Ave NW Apt 4B</code>:</p>
<div class="ss-code language-text codeBlockContainer_mQmQ theme-code-block" style="--prism-color:#f8f8f2;--prism-background-color:#272823"><span class="ss-code-lang">text</span><div class="ss-code-content"><pre tabindex="0" class="prism-code language-text codeBlock_RMoD thin-scrollbar" style="color:#f8f8f2;background-color:#272823"><code class="codeBlockLines_AclH"><div class="token-line" style="color:#f8f8f2"><span class="token plain">v0    : house_number=1600  street="Pennsylvania Ave NW"  unit="4B"  ...</span><br></div><div class="token-line" style="color:#f8f8f2"><span class="token plain">neural: house_number=1600  street="Pennsylvania Ave NW"  unit="Apt 4B"  ...</span><br></div></code></pre><button type="button" class="ss-code-copy" aria-label="Copy code to clipboard"><span aria-live="polite">Copy</span></button></div></div>
<p>Both find the unit. The neural net keeps the designator ("Apt") attached, where v0 strips it to the bare "4B".</p>
<p>None of these are flukes. They're the payoff of a discipline we've written about before — <strong>negative space</strong>: you teach the model the tags the rules engine never had, by training on examples where they appear. The receipts, from our parity evals: <code>unit</code> coverage went from <strong>0 → 92%</strong>, <code>street_prefix</code> 0 → 78%, <code>street_suffix</code> 0 → 67% — components the Pelias port only doesn't emit. And on multi-locale evals the same recipe is why native-order German resolves locality at ~90% where a US-order assumption falls to the 40s (a story we've told before — the right name in the wrong state).</p>
<p>The pattern: on the addresses that fit the template, the parsers tie. On PO boxes, intersections, units, sub-street components, and non-US word order, the neural net is doing something the rules engine architecturally cannot.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="where-we-still-struggle-and-what-we-havent-touched">Where we still struggle (and what we haven't touched)<a href="https://mailwoman.ai/research/a-tie-on-main-street#where-we-still-struggle-and-what-we-havent-touched" class="hash-link" aria-label="Direct link to Where we still struggle (and what we haven't touched)" title="Direct link to Where we still struggle (and what we haven't touched)" translate="no">​</a></h2>
<p>Honesty is the house style, so:</p>
<ul>
<li class=""><strong>Clean addresses are a tie rather than a win.</strong> The two parsers above agree on <code>350 5th Ave</code> down to the components — neural splits <code>street="5th"</code> + <code>street_suffix="Ave"</code> where v0 keeps <code>"5th Ave"</code> whole, and <em>both are correct</em>. For a model this good on US structure, the rules engine is not a thing to beat there. It's a thing to match.</li>
<li class=""><strong>Reordered house numbers.</strong> French addresses that lead with the postcode still trip the model — <code>fr.house_number</code> plateaus around 87% on the hardest reordered stratum, where the model assigns the leading number to <code>house_number</code>. We pushed it from 54% to 87% and then hit a wall that a training-weight tweak won't move. The next change is real reordered data rather than more synthetic.</li>
<li class=""><strong>Whole locales we haven't tackled.</strong> Japanese, Korean, and Taiwanese addressing — block-and-lot systems that don't map onto a Western street grid — are scoped but unbuilt. So is the last stubborn 0% class in the postal arena: Commonwealth and military PO-box formats (BFPO, APO/FPO).</li>
<li class=""><strong>Coordinate precision.</strong> Today's US coordinate is the admin-centroid tier — we land you in the right city, and "right city" is a centroid that can sit tens of kilometers from an edge address. The finer tiers (postcode centroid, then street-level interpolation) are wired but not yet the default everywhere.</li>
</ul>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="we-even-tried-to-have-both">We even tried to have both<a href="https://mailwoman.ai/research/a-tie-on-main-street#we-even-tried-to-have-both" class="hash-link" aria-label="Direct link to We even tried to have both" title="Direct link to We even tried to have both" translate="no">​</a></h2>
<p>If the neural net wins on weird addresses and ties on clean ones, the obvious move is to run both parsers and keep the better answer per component. We built exactly that — a per-component arbitration layer — and the result showed why label-match is the wrong grade.</p>
<p>On our label-match arena, arbitration looked like a triumph: it captured 122 parses that the neural net by itself "missed." Then we graded the <strong>assembled coordinate output</strong> instead of the labels, and it was a catastrophe — locality match fell from 83% to 57%, the median coordinate error blew from 3 km to <strong>over a thousand</strong>, and half the addresses lost their street-and-number entirely.</p>
<p>The diagnosis is the interesting part. The arena's "neural missed it" column conflates two very different things: addresses the neural net gets <em>wrong</em>, and addresses it gets <em>differently right</em>. When v0 writes <code>street="Seminary Dr"</code> and neural writes <code>street="Seminary"</code> + <code>street_suffix="Dr"</code>, arbitrating toward v0 "wins" on label-match and <strong>loses</strong> on the actual parse — and flattening the two parsers' outputs together destroyed the tree structure the resolver needs to tell one "Mill Valley" from another. We rebuilt the layer to preserve that structure. The regression vanished, and so did the result. The safe version is a net wash.</p>
<p>The lesson we're keeping: for a model this strong on the addresses we serve, <strong>blending it with the rules parser left coordinate accuracy where the neural model put it without the blend, once the blend kept the tree intact</strong> — neural isn't wrong where it differs, it's differently right, and "differently right" is not something to overwrite. We grade the coordinate now, never the label, and we let the arbitration implementation sit behind a default-off flag for the day a weaker model or a new locale makes it pay.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="whats-on-deck">What's on deck<a href="https://mailwoman.ai/research/a-tie-on-main-street#whats-on-deck" class="hash-link" aria-label="Direct link to What's on deck" title="Direct link to What's on deck" translate="no">​</a></h2>
<p>The parser is in a good place: at or ahead of a mature rules engine on clean US, and ahead on the hard structured tags. We've now mapped exactly where it isn't. The next moves are about feeding it and placing it more precisely:</p>
<ul>
<li class=""><strong>Real data over synthetic.</strong> Overture Maps address ingestion — to widen the corpus with real reordered, multi-locale, and long-tail formats, the thing the fr-house-number wall told us we need.</li>
<li class=""><strong>Finer coordinate tiers.</strong> Make postcode-centroid and street-level interpolation the default where the data supports it, so "right city" becomes "right block."</li>
<li class=""><strong>Geocode-first record matching.</strong> The other half of the box: once every record resolves to a coordinate, you can match records by where they are rather than how they're spelled — the entity-resolution layer we've been building on top of all of this, and the subject of its own recent post.</li>
</ul>
<p>A tie on Main Street was never the goal. The parser matches a mature rules engine on clean US addresses, leads on difficult structured tags, and has a measured plan for its next kilometer of accuracy.</p>]]></content:encoded>
            <category>Architecture</category>
            <category>Neural classifier</category>
            <category>Evaluation</category>
        </item>
        <item>
            <title><![CDATA[We built the fix for our worst weakness. Three checks made us warrant it.]]></title>
            <link>https://mailwoman.ai/research/three-checks-made-us-warrant-it</link>
            <guid>https://mailwoman.ai/research/three-checks-made-us-warrant-it</guid>
            <pubDate>Wed, 17 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Our failure taxonomy now ranks boundary instability first among the parser's weaknesses. We]]></description>
            <content:encoded><![CDATA[<p>Our failure taxonomy now ranks boundary instability first among the parser's weaknesses. We
spent a night building training data to fix it and then chose not to retrain. Three separate
checks each caught a step that looked reasonable and was wrong, and this post is about those
checks more than the extract. The checks let us work quickly on the worst weakness without
shipping a fix that improves the headline number while introducing hidden errors.</p>
<!-- -->
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-weakness-with-several-faces">The weakness with several faces<a href="https://mailwoman.ai/research/three-checks-made-us-warrant-it#the-weakness-with-several-faces" class="hash-link" aria-label="Direct link to The weakness with several faces" title="Direct link to The weakness with several faces" translate="no">​</a></h2>
<p>Boundary instability means that where two address components meet, the model puts the boundary
in the wrong place. For <code>Country Club Rd</code>, the street name absorbs its own suffix. For
<code>North Sydney NSW 2060</code> with the commas stripped, the city and the state merge. For a French
street like <code>Neuve-des-Capucines 5</code>, the house number is absorbed into the name.</p>
<p>For a while we filed several of these cases under "within-token punctuation" and blamed the
apostrophes and hyphens. When we measured that class, the punctuation was almost never the
cause. <code>O'Connell</code> parses fine, and so does <code>Coeur d'Alene</code>. What breaks is the boundary next to
the punctuation. The same boundary error appears in several forms.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="the-plan">The plan<a href="https://mailwoman.ai/research/three-checks-made-us-warrant-it#the-plan" class="hash-link" aria-label="Direct link to The plan" title="Direct link to The plan" translate="no">​</a></h2>
<p>We planned a synthetic extract that places the gold boundary exactly where the model errs,
spread across enough real-looking addresses that a retrain learns the boundary from context
instead of memorizing the strings. The steps were to generate it, baseline the current model to
measure the size of the gap, write the recipe, and add it to the corpus.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="check-one-the-extract-that-graded-on-a-curve">Check one: the extract that graded on a curve<a href="https://mailwoman.ai/research/three-checks-made-us-warrant-it#check-one-the-extract-that-graded-on-a-curve" class="hash-link" aria-label="Direct link to Check one: the extract that graded on a curve" title="Direct link to Check one: the extract that graded on a curve" translate="no">​</a></h2>
<p>The first version drew from a thin vocabulary of sixteen street names and a handful of cities.
Twenty thousand rows from sixteen streets means the model sees <code>Country Club Rd</code> a thousand
times, so it learns the word and not the boundary. We expected that. We did not expect the
baseline result: the current model scored 48% on the suffix boundary and 70% on the French
prefix against that thin data. Those scores looked reasonable.</p>
<p>We then tripled the vocabulary to a hundred streets and real cities, with every row unique, and
the same model scored 41 and 48. The thin data had two effects. It taught the word instead of
the boundary, and it inflated the baseline score. The runbook already warns that thin diversity
teaches memorization instead of the boundary. The warning now has a measurement next to it.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="check-two-the-postcode-that-was-already-a-house-number">Check two: the postcode that was already a house number<a href="https://mailwoman.ai/research/three-checks-made-us-warrant-it#check-two-the-postcode-that-was-already-a-house-number" class="hash-link" aria-label="Direct link to Check two: the postcode that was already a house number" title="Direct link to Check two: the postcode that was already a house number" translate="no">​</a></h2>
<p>To teach the Australian slash convention, where <code>4/2A</code> means unit 4, number 2A, the extract used
Australian addresses. The base-consistency lint blocked it. In our training corpus the token
<code>3000</code> appears thousands of times, always as a US house number, and the extract labeled it as an
Australian postcode. A coverage extract cannot outweigh the base corpus it is added to. It can
only conflict with the base corpus and degrade results slowly over one to three releases. That has
happened to us before. The lint caught the conflict before we spent any GPU-hours on it. We
removed Australia, the extract now covers base locales only, and the slash convention became a
separate task that must also add base coverage for its locale.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="check-three-the-city-that-was-typically-a-street">Check three: the city that was typically a street<a href="https://mailwoman.ai/research/three-checks-made-us-warrant-it#check-three-the-city-that-was-typically-a-street" class="hash-link" aria-label="Direct link to Check three: the city that was typically a street" title="Direct link to Check three: the city that was typically a street" translate="no">​</a></h2>
<p>With the extract limited to the locales we train on, the lint found another problem. In our
corpus, tokens like Paris, Springfield, and Burlington are mostly streets (Paris Avenue,
Springfield Street) and only rarely cities. Our extract labels them as localities. Either label
can be correct depending on context, and the model may be context-aware enough to handle it. The
lint does not assume that. It flagged the minority sense, because the last time we let a similar
case through, a theater on 5th Avenue caused an extract to label <code>5th Avenue</code> as a venue. The
retrain will track this number, and a cleaner extract will draw its cities from names that the corpus
already labels as cities.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_Qii3" id="why-we-didnt-retrain">Why we didn't retrain<a href="https://mailwoman.ai/research/three-checks-made-us-warrant-it#why-we-didnt-retrain" class="hash-link" aria-label="Direct link to Why we didn't retrain" title="Direct link to Why we didn't retrain" translate="no">​</a></h2>
<p>At the end of the night, the extract was built and the baseline measured. The recipe was written
and approved, the corpus assembled, and the manifest tested against the real base. Every
checklist item was complete, and a training run needed one more command. We deliberately did not
run it.</p>
<p>The three checks were the main work of the night. The taxonomy identified the weakness. The
checks blocked the fix three times, once for the data and twice for the corpus. Each block cost
a few minutes and prevented a retrain whose headline numbers would have looked fine while its
labels were wrong. <strong>The checks give us the fastest way we know to fix a serious weakness correctly: they block a fix until it passes.</strong></p>]]></content:encoded>
            <category>Neural classifier</category>
            <category>Evaluation</category>
            <category>Model training</category>
            <category>Corpus pipeline</category>
        </item>
    </channel>
</rss>