Skip to main content

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 representationhidden stateThe model's internal vector for a token after the encoder has mixed in surrounding context. The contextualized representation the classifier head reads to assign a label. — 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:

  1. The XML serializer already reserves the src attribute (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 without src makes the eventual flip costlier.
  2. 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_id fields 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.
  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. 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 the AddressTree representationhidden stateThe model's internal vector for a token after the encoder has mixed in surrounding context. The contextualized representation the classifier head reads to assign a label..

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 RemoteResolver adapter 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-voltron notes) ship as a known-good packaging. Pure Node via node:sqlite (built-in since Node 22) or better-sqlite3 (lighter dependency surface). Pros: zero new runtime languages, deterministic, offline-capable, fits the existing weights-* 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:

SliceGoalIndependently 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 packageNew 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. integrationResolver 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_id defined in core/types/.

Tasks

  1. 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: extend AddressNode with source?: string and sourceId?: 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 emit AddressNode without these continue to work.
    • Update the file header to describe the provenance fields.
  2. proposalsToTree

    • core/decoder/proposals-to-tree.ts: carry p.source and p.source_id through into each emitted root. Drop the fields when the proposal lacks them (defensive — the type allows it).
  3. buildAddressTree

    • core/decoder/build-tree.ts: optional BuildTreeOpts { 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 stamps source: "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.
  4. XML serializer

    • core/decoder/serialize-xml.ts: emit src="<value>" when node.source or node.sourceId is set. Format: src="<source>:<sourceId>" if both present, src="<source>" if only source. Add includeSrc?: boolean opt (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.
  5. JSON + tuple projections — explicitly unchanged

    • decodeAsJSON stays 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.
    • decodeAsTuples stays [tag, value][]. No provenance.
    • Rationale documented in the file headers.
  6. Tests

    • core/decoder/provenance.test.ts (new): verify the src attr through both proposalsToTree and buildAddressTree paths; verify includeSrc: false suppresses it; verify JSON/tuple projections are unchanged when provenance is set.
    • Update existing serialize.test.ts only if necessary (existing fixtures don't set provenance, so src should 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 decodeAsXML call 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).
  • decodeAsJSON shape 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 what onnxruntime-node already 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/neural or 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 WofSqliteResolver peer 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 placetype taxonomy — the canonical hierarchy walking strategy.
  • project-geocode-earth-voltron operator note — sanity-check the SQLite schema against the source before trusting it.
  • project-mailwoman-licensingWOFWOF (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.