Skip to main content

Read everything a parse returns

Install and first parse prints a tree of tags, values and confidence scores, and stops there. Each of those nodes carries more than it prints, and the pipelinestaged pipelineMailwoman's runtime architecture: a sequence of pure-function stages (normalize → query-shape → locale-gate → kind-classifier → phrase-grouper → classifier → decoder) connected by typed handoffs. Each stage is published as its own npm package. that produced them kept a record. By the end of this page you'll be reading the character spanspanA contiguous range of characters or tokens in the input string, tagged with an address component type (street, locality, postcode, etc.). Parsed addresses are represented as collections of spans, possibly nested in a tree. behind every component, what the confidence number measures and what moves it, the stagestageOne of the dataflow stages in the runtime pipeline (normalize, locale gate, kind classify, phrase group, token classify, sequence correct, reconcile, resolve). Distinct from tier (model vocabulary) and phase (plan milestone). verdicts behind a surprising tag, and the places the resolverresolverThe component that converts parsed address components (locality, region, postcode) into coordinates by looking them up in the gazetteer. The resolver ranks candidates by name match, population, and proximity, and returns the best-matching place with its centroid or polygon. ranked second and third. About fifteen minutes.

Prerequisites

  • The first-parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. install. mailwoman, @mailwoman/neural and @mailwoman/neural-weights-en-us, in an ESM project on Node.js 24.18.0 or later. The full reasoning is in Install and first parse.
  • jq, for steps 4 and 5. Both pipe CLI output through it to cut the fences down to the part under discussion. Drop the pipe if you'd rather read the whole object.
  • candidate.db on disk, for step 5 only. Steps 1 through 4 need no gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture.. Step 5 resolves place names against one, so run Your first ten minutes first if you haven't; that page's download is the same file this one reuses.

1. Print every node

Save this as tree.mjs. It parsesaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. two addresses — the second is the first with the regionregionThe first-level administrative subdivision of a country — a US state, a French region, a province. The component between country and locality. and the postcodepostcodeThe country-specific postal code (US ZIP, French code postal, etc.). Mailwoman handles postcode parsing entirely by rule classifier — a regex problem, not an ML one. removed — and prints every node in the tree rather than the six-key projection.

import { NeuralAddressClassifier } from "@mailwoman/neural"
import { createRuntimePipeline } from "mailwoman"

const classifier = await NeuralAddressClassifier.loadFromWeights({ locale: "en-US" })
const parse = createRuntimePipeline({ classifier })

function walk(raw, node, depth = 0) {
const label = " ".repeat(depth) + node.tag
const span = `[${node.start}, ${node.end})`
const slice = JSON.stringify(raw.slice(node.start, node.end))

console.log(`${label.padEnd(22)} ${span.padEnd(10)} ${slice.padEnd(9)} conf ${node.confidence.toFixed(3)}`)

for (const child of node.children) walk(raw, child, depth + 1)
}

for (const address of ["1 madison ave madison wi 53703", "1 madison ave madison"]) {
const result = await parse(address)

console.log("input: ", JSON.stringify(result.input))
console.log("tree.raw: ", JSON.stringify(result.tree.raw))

for (const root of result.tree.roots) walk(result.tree.raw, root)

console.log()
}
node tree.mjs
input: "1 madison ave madison wi 53703"
tree.raw: "1 Madison Ave Madison WI 53703"
region [22, 24) "WI" conf 0.908
locality [14, 21) "Madison" conf 0.934
street [2, 9) "Madison" conf 0.920
house_number [0, 1) "1" conf 0.852
street_suffix [10, 13) "Ave" conf 0.924
postcode [25, 30) "53703" conf 0.903

input: "1 madison ave madison"
tree.raw: "1 Madison Ave Madison"
locality [14, 21) "Madison" conf 0.793
street [2, 9) "Madison" conf 0.778
house_number [0, 1) "1" conf 0.844
street_suffix [10, 13) "Ave" conf 0.681

The word madison appears twice and carries a different tag each time. That is the case a flat record handles by luck: two keys happen to differ, so nothing collides. The next section is about the field that makes it work by construction.

2. Read the spans

Every node carries start and end. They are character offsets, half-open, and they index tree.raw — the repaired string — rather than the string you passed in. In the first parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. the two Madison spansspanA contiguous range of characters or tokens in the input string, tagged with an address component type (street, locality, postcode, etc.). Parsed addresses are represented as collections of spans, possibly nested in a tree. are [2, 9) and [14, 21), which is what tells you the streetstreetThe named linear feature along which house numbers are ordered. Decomposes into a name plus street affixes; one of the Tier 2 fine labels. one came first.

tree.raw and result.input are separate fields for exactly this reason — the repair that separates them is the one Install and first parse describes. Here it is casing only, so the two strings are the same length and an offset taken against either lands in the same place. That is a property of this input, not a guarantee: slice tree.raw.

SpansspanA contiguous range of characters or tokens in the input string, tagged with an address component type (street, locality, postcode, etc.). Parsed addresses are represented as collections of spans, possibly nested in a tree. are what you build a highlighted input box on, what you use to attribute a correction back to a position, and what you check when a component looks right but came from the wrong part of the string. The tag vocabularyvocabularyThe fixed set of tokens a tokenizer can produce. Mailwoman's SentencePiece vocabulary is tens of thousands of subword pieces, with byte fallback for anything outside it. each node draws from is fixed and documented in SCHEMA.mdx, which is the contract for the ComponentTag union.

3. Read the confidence

A node's confidence is the mean of the softmaxsoftmaxThe function that converts a vector of logits into a probability distribution summing to 1, applied after priors and biases are added to the emission logits. scores the modelneural classifierThe machine learning model at the core of Mailwoman's parser — a transformer encoder (~30M parameters) trained from scratch to do BIO token classification over addresses. It learns the 'grammar' of address formats; the gazetteer supplies the 'atlas.' gave the tokenstokenOne word or subword in the tokenized input. For the neural classifier, tokens come from SentencePiece (subword units); for the rule classifiers, tokens are whitespace- and punctuation-separated words. inside that node's spanspanA contiguous range of characters or tokens in the input string, tagged with an address component type (street, locality, postcode, etc.). Parsed addresses are represented as collections of spans, possibly nested in a tree.. It is not a probability that the tag is correct, and Install and first parse covers the calibration fit that closes the gap between the two readings.

What the second parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. shows is what the number responds to. Dropping wi 53703 costs the streetstreetThe named linear feature along which house numbers are ordered. Decomposes into a name plus street affixes; one of the Tier 2 fine labels. two tokenstokenOne word or subword in the tokenized input. For the neural classifier, tokens come from SentencePiece (subword units); for the rule classifiers, tokens are whitespace- and punctuation-separated words. of context, and every score in the tree falls: localitylocalityThe city / town / settlement component of an address: a populated place sitting between region and neighborhood in the hierarchy. 0.934 to 0.793, streetstreetThe named linear feature along which house numbers are ordered. Decomposes into a name plus street affixes; one of the Tier 2 fine labels. 0.920 to 0.778, and street_suffix 0.681 from 0.924. Nothing about the streetstreetThe named linear feature along which house numbers are ordered. Decomposes into a name plus street affixes; one of the Tier 2 fine labels. changed. The evidence around it did.

Read a low score as "this spanspanA contiguous range of characters or tokens in the input string, tagged with an address component type (street, locality, postcode, etc.). Parsed addresses are represented as collections of spans, possibly nested in a tree. had less to go on". If you plan to threshold on it, calibrate first, for the reason the first-parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. page gives.

4. Ask the pipeline what it decided

The tree is the answer. --debug is the record of the stagesstageOne of the dataflow stages in the runtime pipeline (normalize, locale gate, kind classify, phrase group, token classify, sequence correct, reconcile, resolve). Distinct from tier (model vocabulary) and phase (plan milestone). that produced it — normalization, the structural query shapequery shapeStage 1.5 of the runtime pipeline: computes a structural fingerprint of the input — script class, segmentation, known-format hits (postcode regexes, state abbreviations) — in microseconds without ML. Used by downstream stages for locale detection and kind classification., the locale gatelocale gateStage 2 of the runtime pipeline: rule-based locale detection from the query shape's script and known-format signals. Returns a LocaleHint with the top candidate and alternatives, surfacing disagreement with an explicit --locale flag., the kind classifierkind classifierStage 2.5 of the runtime pipeline: categorizes the input into one of eight query kinds (structured_address, postcode_only, locality_only, intersection, po_box, landmark, poi_query, vague) so the coordinator can route to the right parsing strategy., then the modelneural classifierThe machine learning model at the core of Mailwoman's parser — a transformer encoder (~30M parameters) trained from scratch to do BIO token classification over addresses. It learns the 'grammar' of address formats; the gazetteer supplies the 'atlas.'.

npx mailwoman parse --debug "1 madison ave madison wi 53703" | jq '{path, locale, kind, knownFormats: .queryShape.knownFormats, timing}'
{
"path": "full",
"locale": {
"locale": "en-US",
"confidence": 1,
"alternatives": [
{
"locale": "en-US",
"confidence": 0.5
}
],
"source": "caller"
},
"kind": {
"kind": "structured_address",
"confidence": 0.75,
"alternatives": [
{
"kind": "vague",
"confidence": 0.3
}
]
},
"knownFormats": [
{
"format": "us_zip",
"span": {
"start": 25,
"end": 30,
"body": "53703"
},
"confidence": 0.6
},
{
"format": "fr_postcode",
"span": {
"start": 25,
"end": 30,
"body": "53703"
},
"confidence": 0.6
},
{
"format": "de_postcode",
"span": {
"start": 25,
"end": 30,
"body": "53703"
},
"confidence": 0.6
}
],
"timing": {
"normalize": 0.5091919999999845,
"place-country": 1.043992000000344,
"query-shape": 0.6537020000000666,
"locale-gate": 0.20217800000000352,
"kind-classifier": 0.6429920000000493,
"phrase-grouper": 1.0681070000000545,
"token-classify": 223.19595000000027,
"grouper-audit": 0.14948000000003958
}
}

Four things in there answer "why did that tokentokenOne word or subword in the tokenized input. For the neural classifier, tokens come from SentencePiece (subword units); for the rule classifiers, tokens are whitespace- and punctuation-separated words. go where it went".

knownFormats is the structural read, and it is deliberately non-committal. 53703 matches the US ZIP Code shape, the French postcodepostcodeThe country-specific postal code (US ZIP, French code postal, etc.). Mailwoman handles postcode parsing entirely by rule classifier — a regex problem, not an ML one. shape and the German postcodepostcodeThe country-specific postal code (US ZIP, French code postal, etc.). Mailwoman handles postcode parsing entirely by rule classifier — a regex problem, not an ML one. shape, all at 0.6, because a bare five-digit run is all three. This stagestageOne of the dataflow stages in the runtime pipeline (normalize, locale gate, kind classify, phrase group, token classify, sequence correct, reconcile, resolve). Distinct from tier (model vocabulary) and phase (plan milestone). proposes; it does not pick.

locale.source: "caller" means the gate was not consulted. mailwoman parse defaults --locale to en-US, and a caller-pinned localelocaleThe combination of language and country an address comes from. en-US and fr-FR are the locales Mailwoman ships weights for. is taken as given — which is why the confidence reads 1 while the sole alternative sits at 0.5. Pass a different --locale and this is the field that records it.

kind picks the shape of query, before any component is labeled. structured_address at 0.75 with vague at 0.3 behind it. The other verdicts in that vocabularyvocabularyThe fixed set of tokens a tokenizer can produce. Mailwoman's SentencePiece vocabulary is tens of thousands of subword pieces, with byte fallback for anything outside it.postcode_only, po_box, intersection — route the input down different paths, and path: "full" records which one ran.

timing is per stagestageOne of the dataflow stages in the runtime pipeline (normalize, locale gate, kind classify, phrase group, token classify, sequence correct, reconcile, resolve). Distinct from tier (model vocabulary) and phase (plan milestone)., in milliseconds, for this call. token-classify at 223 ms dominates because this is the first parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. in a fresh process and the modelneural classifierThe machine learning model at the core of Mailwoman's parser — a transformer encoder (~30M parameters) trained from scratch to do BIO token classification over addresses. It learns the 'grammar' of address formats; the gazetteer supplies the 'atlas.' is warming up; the steady-state number for a held pipelinestaged pipelineMailwoman's runtime architecture: a sequence of pure-function stages (normalize → query-shape → locale-gate → kind-classifier → phrase-grouper → classifier → decoder) connected by typed handoffs. Each stage is published as its own npm package. is on the first-parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. page. Everything before it costs about 4 ms combined.

5. See the places it did not pick

Names collide. Drop the state, and Madison has to be chosen rather than looked up. --resolve runs the tree through the gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture. and --candidates keeps the runners-up on each resolved node.

This is the first step on the page that needs the gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture.. Run mailwoman doctor and check that its Admin gazetteer line is green; if it is not, its fix: line is the download. Nothing to export — --resolve finds candidate.db under your data root. Then:

npx mailwoman parse --resolve --candidates 3 "1 madison ave madison" | jq '.roots[0] | {tag, value, placeID, lat, lon, alternatives: [.alternatives[] | {name, placetype, lat, lon, score}]}'
[resolver] candidate-table backend (demo-parity, population-first): /tmp/mw-trial/mailwoman-data/wof/candidate.db
{
"tag": "locality",
"value": "Madison",
"placeID": "wof:101732721",
"lat": 43.08258,
"lon": -89.393029,
"alternatives": [
{
"name": "Madison",
"placetype": "localadmin",
"lat": 43.08258,
"lon": -89.393029,
"score": 5.447632394010742
},
{
"name": "Madison",
"placetype": "locality",
"lat": 34.711251,
"lon": -86.761677,
"score": 4.771087497336895
},
{
"name": "Madison",
"placetype": "locality",
"lat": 32.473765,
"lon": -90.130043,
"score": 4.446971865240103
}
]
}

The first line is the resolverresolverThe component that converts parsed address components (locality, region, postcode) into coordinates by looking them up in the gazetteer. The resolver ranks candidates by name match, population, and proximity, and returns the best-matching place with its centroid or polygon. announcing which backend answered, on standard error.

The winner is Madison, Wisconsin, and the node now carries placeID, lat and lon alongside the tag and the spanspanA contiguous range of characters or tokens in the input string, tagged with an address component type (street, locality, postcode, etc.). Parsed addresses are represented as collections of spans, possibly nested in a tree.. Read the three alternatives in order: the first is the same place at a coarser admin tier (localadmin, same coordinate); the second is Madison, Alabama; the third is Madison, Mississippi. The ranking is population-first, and this gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture.'s own population column puts those three at 280,305, 59,031 and 27,987.

That ordering is a default, not a verdict. When your records carry a state, or a viewport, or a known service area, you have evidence the resolverresolverThe component that converts parsed address components (locality, region, postcode) into coordinates by looking them up in the gazetteer. The resolver ranks candidates by name match, population, and proximity, and returns the best-matching place with its centroid or polygon. did not — so keep the alternatives, filter them yourself, and treat a same-name collision as something to disambiguate rather than something to accept.

What you have now

A reading of the full parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. result: spansspanA contiguous range of characters or tokens in the input string, tagged with an address component type (street, locality, postcode, etc.). Parsed addresses are represented as collections of spans, possibly nested in a tree. that locate every component in the input, a confidence you know how to interpret and how to threshold, the stagestageOne of the dataflow stages in the runtime pipeline (normalize, locale gate, kind classify, phrase group, token classify, sequence correct, reconcile, resolve). Distinct from tier (model vocabulary) and phase (plan milestone). verdicts behind any surprising tag, and the ranked alternatives behind any resolved place nametoponymA proper name for a geographic place.. Nothing here needed a second call — it was all in the object the first parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. already returned.

Next

  • Geocode a CSV of customer addresses — the same result object, applied to a file.
  • Improve geocode precision — what turns a city centroidlocality centroidThe representative center point of a city or locality, used as a coarse coordinate when no exact address point is available — the coarsest tier of the geocode cascade. into a rooftoprooftopGeocoding precision at the building or parcel level — coordinates within a few meters — the highest tier of the geocode cascade. Sourced from address-point and situs data..