Phase 4 — Resolver
Goal: add a 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. layerlayerOne transformer block — attention plus a feed-forward network, with normalization and residual connections — applied to every position. Stacking layers lets the model build up richer representations; Mailwoman's encoder has 6. that takes parsed components and resolves them to canonical place identifiers + coordinates, with source provenance threaded through the output. The parser and geocoder share one representation — the same AddressTree the decoderdecoderIn a transformer encoder-decoder model, the part that produces output sequences. Mailwoman's classifier is encoder-only (no decoder); the 'CRF decoder' is a different thing — a structured-prediction layer that picks the best label sequence from the encoder's outputs. already produces, decorated in-place.
Status: opened 2026-05-20, supersedes the original sketch. PhasesphaseA milestone in the implementation plan (Foundation, Corpus, Training, Integration, and forward-looking phases). Distinct from stage (runtime pipeline) and tier (model vocabulary). 0–3 have shipped (@mailwoman/neural@2.1.0 on npm; CLI + per-component policy live). Real-world deployment has not yet generated feedback, but the team has accepted the architectural risk of beginning PhasephaseA milestone in the implementation plan (Foundation, Corpus, Training, Integration, and forward-looking phases). Distinct from stage (runtime pipeline) and tier (model vocabulary). 4 work now so that the output shape (src attr already reserved on the XML serializer) can land before downstream consumers depend on its absence.
Branch: sub-phasephaseA milestone in the implementation plan (Foundation, Corpus, Training, Integration, and forward-looking phases). Distinct from stage (runtime pipeline) and tier (model vocabulary). branches off main (feature/phase-4-<slice>). Each sub-phasephaseA milestone in the implementation plan (Foundation, Corpus, Training, Integration, and forward-looking phases). Distinct from stage (runtime pipeline) and tier (model vocabulary). ships independently.
Depends on: @mailwoman/core@2.x decoderdecoderIn a transformer encoder-decoder model, the part that produces output sequences. Mailwoman's classifier is encoder-only (no decoder); the 'CRF decoder' is a different thing — a structured-prediction layer that picks the best label sequence from the encoder's outputs. 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. (PR #58 lineage), @mailwoman/neural@2.x.
Why now, why this shape
Three forcing functions:
- The XML serializer already reserves the
srcattribute (serialize-xml.ts:18../../core/decoder/serialize-xml.ts). The TODO comment is a public commitment; shipping a release that adds the attr is non-breaking only because consumers don't depend on its absence yet. Every release that goes out withoutsrcmakes the eventual flip costlier. - The neural classifierneural 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.' emits proposals with
source+source_idfields that the decoderdecoderIn a transformer encoder-decoder model, the part that produces output sequences. Mailwoman's classifier is encoder-only (no decoder); the 'CRF decoder' is a different thing — a structured-prediction layer that picks the best label sequence from the encoder's outputs. discards. That's free debugging signal we're throwing away. - 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. feedback into parsingaddress 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. was the project creator's original vision (per
reference/ARCHITECTURE.md's opening). 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. is not bolted on — it shares theAddressTreerepresentation.
Architecture decision: Option B (SQLite FTS5 + WOF SQLite)
The original sketch listed three options. This plan picks Option B.
- Option A (tantivy / Airmail) — rejected for v1. Introduces Rust into the runtime, which contradicts the project's "TypeScript-first" hard constraint (
docs/plan/README.md). Revisit only if Option B's recallrecallOf the spans whose gold label is a given tag, the fraction the model found. High recall means few misses. Paired with precision to compute F1. floor is unacceptable at planet scale. - Option C (external geocoder API) — rejected as the default. Network dependency + rate limits + privacy implications all hostile to a library that's meant to be embedded. We will expose a
RemoteResolveradapter for users who prefer PeliasPeliasAn open-source geocoder, Mailwoman's spiritual predecessor. / BANBAN (Base Adresse Nationale). France's authoritative open national address register — the highest-quality training source for French addresses, with full component structure. / Nominatim, but the in-package default is local. - Option B (SQLite FTS5FTS5SQLite's built-in full-text-search module (with BM25 ranking). Mailwoman uses it for prefix and name matching against the gazetteer. + WOFWOF (Who's On First). An open-source gazetteer of places maintained by Mapzen/whosonfirst. Mailwoman builds a custom SQLite database from WOF GeoJSON repos, extended with postcode data, importance scores, and coincident-role relations. SQLite) — picked. WOFWOF (Who's On First). An open-source gazetteer of places maintained by Mapzen/whosonfirst. Mailwoman builds a custom SQLite database from WOF GeoJSON repos, extended with postcode data, importance scores, and coincident-role relations. mirrors at data.geocode.earth/wof/dist/sqlite/ (per
project-geocode-earth-voltronnotes) ship as a known-good packaging. Pure Node vianode:sqlite(built-in since Node 22) orbetter-sqlite3(lighter dependency surface). Pros: zero new runtime languages, deterministic, offline-capable, fits the existingweights-*package shape (data packages downloaded on demand). Cons: slower at planet scale than tantivy, simpler ranking — acceptable for v1 because the parser narrows the search space (localitylocalityThe city / town / settlement component of an address: a populated place sitting between region and neighbourhood in the hierarchy. + regionregionThe first-level administrative subdivision of a country — a US state, a French region, a province. The component between country and locality. + countrycountryThe top-level address component (an ISO country). Closed-vocabulary, so it is best handled by a deterministic matcher feeding a proposal rather than a retrained model head. are already extracted).
Sub-phase breakdown
PhasephaseA milestone in the implementation plan (Foundation, Corpus, Training, Integration, and forward-looking phases). Distinct from stage (runtime pipeline) and tier (model vocabulary). 4 ships in three slices, each independently mergeable:
| Slice | Goal | Independently useful? |
|---|---|---|
| 4.1 — Source provenance (this PR) | ThreadthreadA parallel workstream within a release. Threads compose; they are not sequential milestones like phases. source + source_id from ClassificationProposal through the decoderdecoderIn a transformer encoder-decoder model, the part that produces output sequences. Mailwoman's classifier is encoder-only (no decoder); the 'CRF decoder' is a different thing — a structured-prediction layer that picks the best label sequence from the encoder's outputs.; AddressNode gains optional source + sourceId; XML serializer emits src attribute; JSON / tuple projections unchanged. | Yes — surfaces classifier provenance to debug + downstream filtering. No 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. yet. |
| 4.2 — WOFWOF (Who's On First). An open-source gazetteer of places maintained by Mapzen/whosonfirst. Mailwoman builds a custom SQLite database from WOF GeoJSON repos, extended with postcode data, importance scores, and coincident-role relations. SQLite loader package | New package @mailwoman/resolver-wof-sqlite (or fold into @mailwoman/neural? decide in 4.2). Loads a WOFWOF (Who's On First). An open-source gazetteer of places maintained by Mapzen/whosonfirst. Mailwoman builds a custom SQLite database from WOF GeoJSON repos, extended with postcode data, importance scores, and coincident-role relations. SQLite distribution, exposes an FTS5FTS5SQLite's built-in full-text-search module (with BM25 ranking). Mailwoman uses it for prefix and name matching against the gazetteer.-backed lookup findPlace({ locality, region, country, locale }) returning candidate WOFWOF (Who's On First). An open-source gazetteer of places maintained by Mapzen/whosonfirst. Mailwoman builds a custom SQLite database from WOF GeoJSON repos, extended with postcode data, importance scores, and coincident-role relations. places with confidence. | Yes — usable standalone for "what's the WOFWOF (Who's On First). An open-source gazetteer of places maintained by Mapzen/whosonfirst. Mailwoman builds a custom SQLite database from WOF GeoJSON repos, extended with postcode data, importance scores, and coincident-role relations. id for Paris, FR?" without going through the full parser. |
| 4.3 — 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. integration | Resolver interface + WofSqliteResolver impl + resolveTree(tree, resolver) that walks the AddressTree, queries 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. per node, decorates with src="wof-admin:<id>", lat, lon, wof_id. CLI --resolve flag. | Closes the loop — outputs gain real-world identifiers. |
Sub-phasesphaseA milestone in the implementation plan (Foundation, Corpus, Training, Integration, and forward-looking phases). Distinct from stage (runtime pipeline) and tier (model vocabulary). 4.2 and 4.3 will each get their own plan doc (PHASE_4_2_*.md, PHASE_4_3_*.md) written when they begin. This doc is the canonical reference.
Phase 4.1 — Source provenance (current)
Pre-flight
- PR #58 (decoderdecoderIn a transformer encoder-decoder model, the part that produces output sequences. Mailwoman's classifier is encoder-only (no decoder); the 'CRF decoder' is a different thing — a structured-prediction layer that picks the best label sequence from the encoder's outputs. + 3 projections) merged.
-
ClassificationProposal.source+source_iddefined incore/types/.
Tasks
-
DecoderdecoderIn a transformer encoder-decoder model, the part that produces output sequences. Mailwoman's classifier is encoder-only (no decoder); the 'CRF decoder' is a different thing — a structured-prediction layer that picks the best label sequence from the encoder's outputs. types
core/decoder/types.ts: extendAddressNodewithsource?: stringandsourceId?: string. Both optional; the existing decoderdecoderIn a transformer encoder-decoder model, the part that produces output sequences. Mailwoman's classifier is encoder-only (no decoder); the 'CRF decoder' is a different thing — a structured-prediction layer that picks the best label sequence from the encoder's outputs. paths that emitAddressNodewithout these continue to work.- Update the file header to describe the provenance fields.
-
proposalsToTree
core/decoder/proposals-to-tree.ts: carryp.sourceandp.source_idthrough into each emitted root. Drop the fields when the proposal lacks them (defensive — the type allows it).
-
buildAddressTree
core/decoder/build-tree.ts: optionalBuildTreeOpts { source?: string; sourceId?: string }param. The neural 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.'s caller stampssource: "neural"+sourceId: <model-card-version>on every emitted 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.. No per-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. variation here — one 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.', one source.
-
XML serializer
core/decoder/serialize-xml.ts: emitsrc="<value>"whennode.sourceornode.sourceIdis set. Format:src="<source>:<sourceId>"if both present,src="<source>"if only source. AddincludeSrc?: booleanopt (default true) for callers who want to suppress.- Update the file header: drop "reserved for PhasephaseA milestone in the implementation plan (Foundation, Corpus, Training, Integration, and forward-looking phases). Distinct from stage (runtime pipeline) and tier (model vocabulary). 4" wording; replace with the actual semantics.
-
JSON + tuple projections — explicitly unchanged
decodeAsJSONstays libpostallibpostalAn open-source C address parser used by Pelias. Mailwoman's rule-based v0 and neural classifier supersede it.-compat (shape:{ tag: value }). No provenance.decodeAsTuplesstays[tag, value][]. No provenance.- Rationale documented in the file headers.
-
Tests
core/decoder/provenance.test.ts(new): verify thesrcattr through bothproposalsToTreeandbuildAddressTreepaths; verifyincludeSrc: falsesuppresses it; verify JSON/tuple projections are unchanged when provenance is set.- Update existing
serialize.test.tsonly if necessary (existing fixtures don't set provenance, sosrcshould be absent — that's a featurefeatureAn input signal a model conditions on. Beyond the raw tokens, Mailwoman feeds soft features — gazetteer-membership channels and the postcode anchor — that inform predictions without overriding them.).
Success criteria
- All existing decoderdecoderIn a transformer encoder-decoder model, the part that produces output sequences. Mailwoman's classifier is encoder-only (no decoder); the 'CRF decoder' is a different thing — a structured-prediction layer that picks the best label sequence from the encoder's outputs. tests pass unchanged.
- New provenance test passes for both decoderdecoderIn a transformer encoder-decoder model, the part that produces output sequences. Mailwoman's classifier is encoder-only (no decoder); the 'CRF decoder' is a different thing — a structured-prediction layer that picks the best label sequence from the encoder's outputs. entry points.
- A
decodeAsXMLcall on a proposal-derived tree emits<locality src="rule:whos_on_first" ...>Paris</locality>-style output.
Out of scope for 4.1
- 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. lookup (4.3).
- Lat/lon attrs (4.3).
- WOFWOF (Who's On First). An open-source gazetteer of places maintained by Mapzen/whosonfirst. Mailwoman builds a custom SQLite database from WOF GeoJSON repos, extended with postcode data, importance scores, and coincident-role relations. SQLite loader (4.2).
decodeAsJSONshape change (deferred indefinitely; libpostallibpostalAn open-source C address parser used by Pelias. Mailwoman's rule-based v0 and neural classifier supersede it. compat is essential).
Phase 4.2 — WOF SQLite loader (sketch)
Standalone package. Loads a WOFWOF (Who's On First). An open-source gazetteer of places maintained by Mapzen/whosonfirst. Mailwoman builds a custom SQLite database from WOF GeoJSON repos, extended with postcode data, importance scores, and coincident-role relations. SQLite distribution from a path or URL. Exposes:
interface WofPlace {
wof_id: number
name: string
placetype: "country" | "region" | "locality" | "neighbourhood" | "microhood" | ...
lat: number
lon: number
parent_id?: number
country: string // ISO-3166 alpha-2
}
interface PlaceLookup {
findPlace(query: {
text: string
placetype?: WofPlace["placetype"]
country?: string
parentId?: number
}): Promise<Array<{ place: WofPlace; score: number }>>
}
FTS5FTS5SQLite's built-in full-text-search module (with BM25 ranking). Mailwoman uses it for prefix and name matching against the gazetteer. over wof.name + wof.name_alts. Score = FTS5FTS5SQLite's built-in full-text-search module (with BM25 ranking). Mailwoman uses it for prefix and name matching against the gazetteer. BM25 + boosts for placetypeplacetypeThe Who's On First hierarchical classification of places: planet → continent → country → region → county → locality → neighbourhood. The resolver uses placetype to rank candidates — an exact locality match outranks a county-level match. + countrycountryThe top-level address component (an ISO country). Closed-vocabulary, so it is best handled by a deterministic matcher feeding a proposal rather than a retrained model head. match. Distribution-versioning piggy-backs on the existing neural-weights-* pattern: @mailwoman/wof-sqlite-<region> packages, one per geographic shardshardA partial output file of the corpus build, written in Parquet format. The training pipeline streams shards row by row., pulled on demand.
Decisions deferred to 4.2:
- Sync (
better-sqlite3) vs async (node:sqlite+Worker) — depends on whatonnxruntime-nodealready does and whether 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. lookups end up in a hot loop. - Whether to fold into
@mailwoman/neuralor split — split is cleaner but means another package to publish. - RegionregionThe first-level administrative subdivision of a country — a US state, a French region, a province. The component between country and locality. sharding strategy (US-only first vs full planet vs admin-2 shardsshardA partial output file of the corpus build, written in Parquet format. The training pipeline streams shards row by row.).
Phase 4.3 — Resolver integration (sketch)
Resolver interface composes a PlaceLookup (4.2) with the AddressTree:
interface Resolver {
resolveTree(tree: AddressTree, opts?: ResolveOpts): Promise<AddressTree>
}
Walk the tree top-down (countrycountryThe top-level address component (an ISO country). Closed-vocabulary, so it is best handled by a deterministic matcher feeding a proposal rather than a retrained model head. → regionregionThe first-level administrative subdivision of a country — a US state, a French region, a province. The component between country and locality. → localitylocalityThe city / town / settlement component of an address: a populated place sitting between region and neighbourhood in the hierarchy. → ...), use each resolved parent's wof_id to constrain the child lookup. Decorate matched nodes with source: "resolver", sourceId: "wof-admin:<wof_id>", and new fields on AddressNode: lat?: number; lon?: number; placeId?: string. The XML serializer gains those as additional attributes when present.
When 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. "wins" attribution, the classifier's original source moves into metadata so debugging tools can still see it. The XML attr shows the winning source.
CLI: mailwoman parse --resolve --format xml toggles 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. on. Default off until 4.3 ships.
Decisions deferred until 4.2 / 4.3 begin
- Feedback loop (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.-corrects-parser) — not in v1. The output is decorated, not rewritten. A future sub-phasephaseA milestone in the implementation plan (Foundation, Corpus, Training, Integration, and forward-looking phases). Distinct from stage (runtime pipeline) and tier (model vocabulary). 4.4 can add the loop.
- Whether to expose the joint
{tree, resolution}type publicly — deferred to 4.3. - BANBAN (Base Adresse Nationale). France's authoritative open national address register — the highest-quality training source for French addresses, with full component structure.-specific 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. for FR — likely a separate
WofSqliteResolverpeer using the BANBAN (Base Adresse Nationale). France's authoritative open national address register — the highest-quality training source for French addresses, with full component structure. data, gated on whether WOFWOF (Who's On First). An open-source gazetteer of places maintained by Mapzen/whosonfirst. Mailwoman builds a custom SQLite database from WOF GeoJSON repos, extended with postcode data, importance scores, and coincident-role relations.'s France coveragecoverageThe fraction of a population or region for which a data source has real, non-placeholder entries — e.g. 47% rooftop coverage on Texas addresses. Distinct from accuracy on the rows that are present. is acceptable in the evalevalRunning the model against a held-out golden dataset and computing per-component F1, exact-match, calibration, and resolved-coordinate error. set. Defer until 4.3 hits the evalevalRunning the model against a held-out golden dataset and computing per-component F1, exact-match, calibration, and resolved-coordinate error. bench.
Reading material to revisit at each sub-phase
ellenhp/airmail— even though Option A is rejected, the indexer's ranking heuristics are worth borrowing.pelias/placeholder— closest prior art for Option B; cribbing welcome.- WOFWOF (Who's On First). An open-source gazetteer of places maintained by Mapzen/whosonfirst. Mailwoman builds a custom SQLite database from WOF GeoJSON repos, extended with postcode data, importance scores, and coincident-role relations.'s
placetypetaxonomy — the canonical hierarchy walking strategy. project-geocode-earth-voltronoperator note — sanity-check the SQLite schema against the source before trusting it.project-mailwoman-licensing— WOFWOF (Who's On First). An open-source gazetteer of places maintained by Mapzen/whosonfirst. Mailwoman builds a custom SQLite database from WOF GeoJSON repos, extended with postcode data, importance scores, and coincident-role relations. is CC-BY 4.0; attribution required in any redistribution. 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. package's README must carry it.
Changelog
- 2026-05-20 — sketch (the original three-option overview) replaced with this detailed plan. Picked Option B. Defined sub-phasesphaseA milestone in the implementation plan (Foundation, Corpus, Training, Integration, and forward-looking phases). Distinct from stage (runtime pipeline) and tier (model vocabulary). 4.1 / 4.2 / 4.3 and started 4.1.