mailfail — robustness on garbage, malformed and hostile input
Date: 2026-08-02 · Scope: what the shipped stack does with input that is not an address.
Read-only investigation; no production code changed. 111 probe inputs across seven classes, run on
four paths, with the fixture committed at
mailwoman/eval-harness/fixtures/mailfail.jsonl so these cases can become a regression test.
Verdict
Three findings need action, and one of them is not a garbage-input problem at all.
The stack is well-behaved on the input classes you would expect to break it. Empty strings, whitespace, punctuation runs, emoji, ZWJ sequences, box drawing, unpaired surrogates, embedded NUL, BOM, zalgo, RTL overrides, CJK and Devanagari all pass through without a throw and mostly without emitting anything. That is a real clean bill of health and it is stated plainly below rather than buried.
What breaks is length. Two independent quadratics live in the preprocessing 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)., and a sequence-length cap in the ONNXONNX (Open Neural Network Exchange). An open format for machine learning models that enables interoperability between training frameworks and inference runtimes. Mailwoman ships its trained model as an ONNX file so it can run in Node.js and the browser via onnxruntime. runner desynchronises two arrays 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. then indexes in lockstep. That last one is the finding that matters most, because it fires on a plausible real address of 325 characters — not on garbage — and it is reachable from the shipped geocode path.
And separately: a single common English word, a single letter, or a single digit will resolve to a real coordinate somewhere in the world, with 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. score indistinguishable from a correct hit.
What was measured, and on which path
Four paths, because they behave differently and carry different risk:
| Path | How it was driven | Who is on it |
|---|---|---|
| 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). | normalize → computeQueryShape → detectLocale → classifyKindSync → groupPhrasesSync, timed individually. No 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.'. | everything downstream |
| 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. | NeuralAddressClassifier.parse with the exact opts parseForGeocode uses: postcodeRepair: true, normalizeCase: true, queryShape, enforceWordConsistency: WORD_CONSISTENCY_SHIP_DEFAULT. | geocode-core.ts — the drop-in servers and the geocode CLI |
| 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. | createRuntimePipeline({ classifier }), 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.. | mailwoman parse |
| resolve | createRuntimePipeline({ classifier, resolver }) over admin-global-priority.db (5.2 GB, FTS backend). | mailwoman parse --resolve |
The raw classifier defaults enforceWordConsistency to OFF; every measurement here passes
WORD_CONSISTENCY_SHIP_DEFAULT so it reflects what a consumer runs.
Measurement scepticism. The first resolve run reported zero garbage-to-coordinates, which would
have been a headline "clean" result. It was wrong: the walker looked for node.resolved.latitude,
and resolved coordinates land on node.lat / node.lon. A positive control — a real
address that must resolve — caught it. The control is now the first row of every resolve run, and
the 35 hits below are from the run where it passed. Two of those hits were re-confirmed through
the shipped CLI (mailwoman parse --resolve) rather than the evalevalRunning the model against a held-out golden dataset and computing per-component F1, exact-match, calibration, and resolved-coordinate error. script.
A second evalevalRunning the model against a held-out golden dataset and computing per-component F1, exact-match, calibration, and resolved-coordinate error.-script bug is worth recording because it produced a number that briefly went into this
report. The 1 MB run was watched with a shell loop whose own command line contained the string
mailfail-e2e, so pgrep -f mailfail-e2e matched the watcher, not the job — and
ps -o etimes was reporting the watcher's age as if it were the measurement. That is how ">5.5
minutes, still running" got written down for a job that had in fact been killed. The real figure
was recovered from the log file's mtime and the job's own wall-clock cap. Any process-liveness
check whose pattern can match the checker is measuring itself.
Finding 1 — a 325-character real address throws an uncaught TypeError
Severity: high. Availability and correctness, on the shipped geocode path, triggered by valid input.
OnnxRunner.infer clamps 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.' input to a fixed window:
// neural/onnx-runner.ts:197
const seqLen = Math.min(tokenIDs.length, this.fixedSeqLen) // fixedSeqLen defaults to 128
logits and emissions come back with exactly seqLen rows. pieces — from
tokenizer.encode at neural/classifier.ts:662 — is never truncated to match. 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. then
walks them together:
// neural/classifier.ts:959-961
let tokens: DecoderToken[] = pieces.map((p, i) => {
const idx = labelIndices[i]!
const probs = softmax(logits[i]!) // logits[i] is undefined for i >= 128
softmax(undefined) reads row[0] at neural/viterbi.ts:280 and throws
TypeError: Cannot read properties of undefined (reading '0').
There is a second, earlier throw site on the same desync. enforceWordConsistency — default-ON in
production — iterates piece indices but indexes emissions:
// neural/word-consistency.ts:238
const probs = softmax([...emissions[pi]!]) // TypeError: emissions[pi] is not iterable
Whichever fires first depends on whether a word straddles the boundary with mixed labelscomponent tagOne of the 25 labels in Mailwoman's address schema — street, locality, region, postcode, house_number, unit, po_box, country, venue, intersection, and others. Each parsed span carries exactly one component tag..
The boundary is exactly 128 piecesECE (Expected Calibration Error). A metric that measures how well a model's confidence scores align with its actual accuracy. Lower is better. Mailwoman's held-out ECE drops from 0.067 (raw) to 0.0035 (calibrated).. Measured with a controlled one-piece-per-character input:
digits=128 pieces=128 -> ok
digits=129 pieces=129 -> THREW
It fires on plausible addresses. Address-like text runs about 1.8–2.5 characters per piece, so 128 piecesECE (Expected Calibration Error). A metric that measures how well a model's confidence scores align with its actual accuracy. Lower is better. Mailwoman's held-out ECE drops from 0.067 (raw) to 0.0035 (calibrated). is roughly 320 characters. Bisecting a realistic form-concatenated delivery address (c/o line, building, floor, suite, streetstreetThe named linear feature along which house numbers are ordered. Decomposes into a name plus street affixes; one of the Tier 2 fine labels., boroughboroughAn administrative or historical division of a city — e.g. the five boroughs of New York City. May be postal, legal, or both, and complicates the locality hierarchy., citylocalityThe city / town / settlement component of an address: a populated place sitting between region and neighborhood in the hierarchy., state, ZIP+4, 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., delivery window):
last OK length: 320 chars (127 pieces)
first THROW : 325 chars (131 pieces)
That is not exotic. Shipping systemsexpectation-maximizationAn iterative algorithm that estimates model parameters when some variables are unobserved. In Mailwoman's matcher, EM learns the Fellegi-Sunter m and u parameters from unlabeled data — no training labels needed., CRMs and government forms concatenate address lines to well past 325 characters routinely.
The 512-character drop-in cap does not protect against this. nominatim/cli.ts:67 and
photon/cli.ts:60 both cap at MAX_QUERY_LEN = 512, with a comment explaining the cap exists
because a long query "would exceed 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.''s input window." The cap is roughly 4× too loose: a
415-character query — comfortably under it — tokenizes to 178 piecesECE (Expected Calibration Error). A metric that measures how well a model's confidence scores align with its actual accuracy. Lower is better. Mailwoman's held-out ECE drops from 0.067 (raw) to 0.0035 (calibrated). and throws.
What each caller sees:
parseForGeocode/geocodeAddress(mailwoman/geocode-core.ts) do not catch. The throw propagates. Nominatim/search, Photon/api, the libpostallibpostalAn open-source C address parser used by Pelias. Mailwoman's rule-based v0 and neural classifier supersede it. drop-in and the geocode CLI all reach the parser through here.- The four Hono servers each install
app.onError(nominatim/app.ts:72,photon/app.ts:73,libpostal/app.ts:78,api/app.ts:110), so over the wire this is an HTTP 500 "internal error", not a process crash. Contained, but a 500 on a valid address is a product defect. - Library and CLI consumers of
geocodeAddressget an uncaughtTypeError. runPipelinedoes catch, atcore/pipeline/runtime-pipeline.ts:648(safeClassify) — see Finding 4, because the catch is not the mercy it looks like.
Suggested fix. Truncate pieces to the runner's fixedSeqLen immediately after
classifier.ts:662, so every downstream array is the same length by construction. That kills both
throw sites and stops query-shape-prior.ts / span-proposal-prior.ts allocating
pieces.length × labels.length matrices whose tail rows addEmissionMatrix discards anyway. It
also makes the truncationtruncationCutting an input down to the max sequence length (or an LLM response to its token limit), discarding everything past the cap. visible — today a 400-character address is silently parsed from its
first ~320 characters with no signal to the caller, which is its own reportable defect. Tightening
MAX_QUERY_LEN to ~300 would bound the worst case on two of the four servers but is a
mitigation, not the fix.
Finding 2 — computeQueryShape is quadratic in segment count
Severity: high. Availability. Reachable from every path, including parseForGeocode.
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).-level timings, no 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.' involved:
| input | chars | segmentssegmentA punctuation-bounded chunk of the normalized input — the comma-separated parts of 'Portland, OR' — used to give downstream stages structural context. | computeQueryShape |
|---|---|---|---|
| repeated address, 1 KB | 990 | 61 | 1.7 ms |
| repeated address, 10 KB | 9,999 | 607 | 11.0 ms |
| repeated address, 100 KB | 100,023 | 6,063 | 1,266.8 ms |
| repeated address, 1 MB | 1,000,032 | 60,609 | 110,630.1 ms |
Ten times the segmentssegmentA punctuation-bounded chunk of the normalized input — the comma-separated parts of 'Portland, OR' — used to give downstream stages structural context., roughly a hundred times the work. normalize over the same ladder is
linear (394.8 ms at 1 MB) and the grouper is linear on this shape, so query-shape is 98% of the
1 MB cost.
Isolating the sub-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). at 100 KB puts 1,228.9 ms of the 1,266.8 ms in one function:
chars=100023 tok=21217 seg=6063 | tokenize=3.0 classify=1.4 segment=8.0 knownFormats=13.8 regionAbbrev=1228.9 fold=0.1
The cause is a plain nested loop over two collections that both grow with input length:
// query-shape/region-abbreviations.ts:28-40
for (const seg of segments) {
if (seg.separator !== "comma") continue
for (const tok of tokens) {
if (tok.span.start < seg.span.start || tok.span.end > seg.span.end) continue
...
At 1 MB that is 60,609 × 212,170 ≈ 12.9 billion iterations.
A comma-dense input with no whitespace ("a," repeated) hits the same quadratic through segmentsegmentA punctuation-bounded chunk of the normalized input — the comma-separated parts of 'Portland, OR' — used to give downstream stages structural context.
count alone: 3.8 ms → 103.4 ms across a 16× size increase.
Suggested fix. Both arrays are position-sorted, so a two-pointer merge makes this O(S + T) with
no behaviour change. Filtering 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. by REGION_ABBREV_RE and class before the segmentsegmentA punctuation-bounded chunk of the normalized input — the comma-separated parts of 'Portland, OR' — used to give downstream stages structural context. loop is
a cheaper partial mitigation but stays quadratic when the abbreviation repeats.
Finding 3 — the phrase grouper is quadratic on capitalized and street-suffix runs
Severity: high. Availability. This is the worst of the three by constant factor.
phrase-grouper/rules.ts:718-780 runs an outer loop over 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. with an inner unbounded walk to
the end of the capitalized run, and explicitly declines to skip past the run
(rules.ts:812-814). The 6-proposal cap at rules.ts:780 bounds emission, not the walk — so the
adjacent comment claiming "O(n) per segmentsegmentA punctuation-bounded chunk of the normalized input — the comma-separated parts of 'Portland, OR' — used to give downstream stages structural context." is wrong. rules.ts:613-627 has the same shape for
street suffixesstreet affixA modifier on a street name indicating type or direction — Street, Avenue, rue, Calle, N, East. Mailwoman tags these as street_prefix / street_suffix, recognized via a morphology FST..
Grouper time, by input shape and repetition count:
| n | chars | Aa repeated | St repeated | repeated address (baseline) |
|---|---|---|---|---|
| 500 | 1,500 | 49.7 ms | 11.4 ms | 3.4 ms |
| 1,000 | 3,000 | 138.3 ms | 33.8 ms | 5.1 ms |
| 2,000 | 6,000 | 397.1 ms | 87.3 ms | 10.0 ms |
| 4,000 | 12,000 | 1,382.8 ms | 387.1 ms | 20.4 ms |
| 8,000 | 24,000 | 5,328.1 ms | 1,461.2 ms | 38.3 ms |
The baseline column is the control: a real repeated address of comparable size costs 38 ms. The capitalized-run shape costs 5,328 ms — 139× more for the same byte count, because commas break the run and cap the walk.
End-to-end through createRuntimePipeline:
repeated-address 100 KB 100023 chars 18.99 s
capitalized-run 100 KB 99999 chars 156.42 s
156 seconds of blocked event loop for 100 KB of "Aa Aa Aa …". Node is single-threaded; that
is the whole server.
Suggested fix. A 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.-count guard in groupPhrasesSync (phrase-grouper/group.ts:74, which
today only checks if (!text.length) return []) bounds this cheaply. The structural fix is to
advance i past the consumed run, or memoize the run-end per start index.
Exposure map
Where the two quadratics and the 128-piece throw can be reached:
| Surface | Length guard | Exposed to |
|---|---|---|
@mailwoman/api /v1/parse, /v1/geocode | 2 MiB body limit (api/app.ts:23); no per-string cap — address: z.string() at api/schema.ts:38,68 carries no .max() | both quadratics, the 128-piece throw |
@mailwoman/api /v1/batch | 2 MiB body, 500 rows (api/routes.ts:49) | both quadratics × up to 500 rows |
@mailwoman/libpostal /parse | 100 KB body (libpostal/app.ts:24); no per-query cap | both quadratics (bounded at 100 KB ≈ 19 s), the 128-piece throw |
@mailwoman/nominatim /search | MAX_QUERY_LEN = 512 (nominatim/cli.ts:67) | 128-piece throw only — quadratics are bounded harmless at 512 chars |
@mailwoman/photon /api | MAX_QUERY_LEN = 512 (photon/cli.ts:60) | same |
| CLI / library | none | all three |
The single highest-value mitigation is a .max() on the API address field. It closes the
quadratics on the most exposed surface without touching the parser.
I did not stand up a server and issue HTTP requests. The timings above are of the 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 endpoint performs, measured in-process. The exposure column is read from the code, not observed over the wire.
Finding 4 — the pipeline masks the classifier crash and substitutes rule-based output
Severity: medium. Silent wrongness.
safeClassify catches any classifier throw and returns { raw: text, roots: [] }
(core/pipeline/runtime-pipeline.ts:648). The later grouper/reconcile 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). then repopulate the
tree from rule-based proposals. The caller gets a normal-looking 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. with no indication 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.'
never ran.
Instrumenting 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. to record whether the inner classifier threw, 10 of 110 probes crashed the classifier while 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. reported success:
| probe | bytes | inner throw | 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. output |
|---|---|---|---|
size-10kb | 9,999 | emissions[pi] is not iterable | {"house_number":"350","street":"5th Ave","locality":"Ave","region":"NY","postcode":"10118"} |
size-1kb | 990 | emissions[pi] is not iterable | same |
adv-repeat-suite-100 | 900 | Cannot read properties of undefined | {"locality":"Suite","house_number":"1"} |
num-very-long-digits | 200 | emissions[pi] is not iterable | {"house_number":"9"×144} |
The size-10kb row is the sharp one: 10 KB of repeated addresses yields a tidy five-field 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.
that looks like a correct read of one address. The tree behind it holds 3,031 nodes.
The num-very-long-digits row exposes a second, unrelated issue: the emitted house_number is
144 characters, truncated from 200 by MAX_SPAN_LENGTH = 140 at core/tokenization/Span.ts:17,132,
which recomputes this.end from the truncated body — so 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.'s offsets no longer point at the
input it came from.
Suggested fix. Fixing Finding 1 removes the throw, which removes most of this. Independently,
safeClassify swallowing every error with a bare catch {} means a 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.' fault is
indistinguishable from a clean no-match. Surfacing it on PipelineResult (a classifierError
field, or a path marker) would cost nothing and make this class self-reporting.
Finding 5 — garbage resolves to real coordinates
Severity: medium. Silent wrongness, not availability. Inherent to 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. breadth, but currently unmitigated.
35 of 110 probes produced at least one coordinate on the --resolve path. The mechanism is
mundane: 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. contains small populated places named Null, Drop, Boom, Quote, Home, Aug,
Amet, All, Hello, Apt, Ave, A, x, Purwa 0, Zona 1. Any input containing one of
those 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. can be tagged locality and resolved.
| input | class | resolves to |
|---|---|---|
+1 (555) 867-5309 | phone number | Zona 1, Guatemala City — 14.6369, −90.5102 |
null undefined NaN None nil void | null literals | Null, India — 26.5482, 80.8319 |
'; DROP TABLE places; -- | SQL-injection-shaped | Drop, Texas — 33.1309, −97.3559 |
Error: boom\n at parse (/app/index.js:12:9) | JS stack trace | Boom, Belgium — 51.0924, 4.3717 |
127.0.0.1 - - [02/Aug/2026:...] "GET /v1/parse" | nginx log line | Aug, Austria — 46.8433, 15.7970 |
/home/lab/Projects/mailwoman/core/data/wof.db | unix path | Home, Washington — 47.2788, −122.7748 |
Lorem ipsum dolor sit amet, … | lorem ipsum | Amet, India — 25.2761, 73.9231 |
Ignore all previous instructions and … | prompt-injection-shaped | All, Andorra — 42.3974, 1.8402 |
┌─────────┐│ hello │└─────────┘ | box drawing | Hello, Ghana — 10.4500, −3.1333 |
"unclosed quote | unbalanced quote | Quote, Missouri — 39.5392, −93.6833 |
a | one letter | A, Nebraska — 41.0027, −96.9678 |
-0 | negative zero | Purwa 0, India — 25.5927, 82.6826 |
| zalgo text | combining marks | four localities across India and the Philippines |
Two of these were re-confirmed through the shipped CLI rather than the evalevalRunning the model against a held-out golden dataset and computing per-component F1, exact-match, calibration, and resolved-coordinate error. script. +1 (555) 867-5309 under --resolve (which applies defaultCountry=US from --locale en-US) lands on a
Nebraska place named 1 at 41.4345, −96.0268 instead of Guatemala — different point, same defect.
The confidences do not flag these, and neither does 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. score. 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.' confidence on the emitted components:
num-phone-us locality="1" @ 0.964 postcode="867-5309" @ 0.643
adv-sql-inject locality="DROP TABLE" @ 0.875
adv-lorem street="consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore" @ 0.871
adv-prompt house_number="51" @ 0.851
adv-null-words locality="null undefined NaN None" @ 0.830
A phone number is read as a localitylocalityThe city / town / settlement component of an address: a populated place sitting between region and neighborhood in the hierarchy. at 0.964. On 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. side, the correct control
(New York) scored 29.2 while Apt → Apt, France scored 27.0 and Null → Null, India scored
27.0. There is no threshold on either signal that separates these from a correct hit.
Assessment. This is not straightforwardly a bug — Boom is a real Belgian municipalitylocalityThe city / town / settlement component of an address: a populated place sitting between region and neighborhood in the hierarchy. and a
geocoder should find it. The defect is that nothing downstream can tell the two cases apart. A
plausible mitigation is a population/importance floor for single-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. localities with no
corroborating component (no house numberhouse numberThe numeric or alphanumeric identifier of a building on a street. Mailwoman's house_number component; its position relative to the street name flips between locales., no postcodepostcodeThe country-specific postcode (US ZIP, French code postal, etc.). Mailwoman handles postcode parsing entirely by rule classifier — a regex problem, not an ML one., no regionregionThe first-level administrative subdivision of a country — a US state, a French region, a province. The component between country and locality.), which would drop the whole
table above while leaving Springfield — a bare citylocalityThe city / town / settlement component of an address: a populated place sitting between region and neighborhood in the hierarchy. name a user might type — intact.
That trades 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. for precisionprecisionOf the spans the model labeled as a given tag, the fraction it got right. High precision means few false positives. Paired with recall to compute F1. and should be measured against the fragment boardsfragment boardThe standing targeted-failure-class eval: ~400 rows per pre-registered class (homonym, bare-street, bare-locality, housenumber variants, particles, date-names) sampled from a national source, graded with Wilson intervals, with the NEGATIVE class scored (a hallucinated street on a bare locality is visible by construction). The FR board (ban-fragments-fr) is the P0 instrument; the DE board (overture-fragments-de) is the v7-fold gate's leg. before anyone
ships it.
What held up
Stated plainly, because these are real results and not padding. Across all four paths, none of the following threw, hung, or emitted anything:
- Degenerate input is clean. Empty string, single/multiple spaces, tab, newline, CRLF, only
newlines,
.,,, ten commas,,interleaved with spaces,-,",\— every one returns{}with no throw. Single alphanumerics (a,X,7,0) emit one component, which is defensible for a single-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. query; onlyaresolves. - Emoji. Single emoji, ten building emoji, ZWJ family sequences, skin-tone modifiers,
regional-indicator flags — all emit nothing. Keycap sequences (
1️⃣2️⃣3️⃣) emit{"locality":"3","street":"2","house_number":"1"}, which is the digits inside them being read, not an emoji failure. - Encoding stress. Unpaired high and low surrogates, embedded NUL, BOM, zero-width spaces inside 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., RTL override wrapping, NFD-decomposed accents, fullwidth Latin, non-breaking spaces, U+2028/U+2029 — no throw anywhere. The BOM and line-separator cases still 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. and resolve the underlying address correctly.
- Scripts. Arabic, Hebrew, Chinese, Japanese, Korean, Devanagari (with Devanagari digits), Thai (with Thai digits), and a five-script mixed line all handled. The Hebrew address resolves to Tel Aviv correctly.
- Structured data. JSON, XML, HTML, YAML, CSV, base64, URLs, Windows paths, email addresses, UUIDs, 200-deep bracket nesting — no throw. Several resolve (Finding 5), none crash.
- Numeric nonsense. ISO dates, datetimes, latitude/longitude pairs, DMS coordinates, IPv4, UUIDs,
SSN-shaped digits, scientific notation, credit-card-shaped digits — none resolve. The bare
latitude/longitude pair
40.748817, -73.985428correctly yields no coordinate.
One untidy case worth a line: \\x01\\x02\\x03\\x07\\x1b[31m (control characters plus an ANSI
colour escape) emits {"postcode":"31m"}. Cosmetic, low severity, but the control bytes should
have been stripped in 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). 1.
A predicted problem that is not real. The static pass flagged
neural/span-proposer-lexicon.ts:138 as polynomial-ReDoS-shaped — \s*#?\s* before a required
digit, on a default-ON path. Measured against "PO Box" + " ".repeat(n) + "x" for n up to 8,000,
the time is flat at 0.1–0.6 ms with no growth. Ruled out; recording it so nobody re-derives it.
What I did not test
- No HTTP-level testing. No server was started, no request issued. Timings are of the 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 endpoint performs. The exposure map is read from source.
- No concurrency or sustained-load testing. Every measurement is a single call on an idle process. Event-loop blockingblockingThe first stage of entity resolution: generate candidate record pairs using cheap, high-recall keys (geo cell, canonical address, phone) instead of comparing every record to every other (O(n²)). The matcher only scores pairs that survive blocking. is inferred from wall time on a single-threaded runtime, not observed under load.
- No memory-pressure measurement. RSS and GC behaviour were not instrumented. The
pieces.length × labels.lengthallocations noted in Finding 1 are read from source, not measured. - No localelocaleThe combination of language and country an address comes from. en-US and fr-FR are the locales Mailwoman ships weights for. 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. beyond en-US. The classifier is
loadFromWeights({ locale: "en-US" })throughout. The two quadratics are localelocaleThe combination of language and country an address comes from. en-US and fr-FR are the locales Mailwoman ships weights for.-independent (both are in pre-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.' 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).), but the crash boundary is a tokenizertokenizerThe component that converts a raw address string into a sequence of numeric token IDs the model can process. Mailwoman's tokenizer is a SentencePiece unigram model trained specifically on postal addresses. property and the piece-per-character ratio will differ for non-Latin scripts — 128 piecesECE (Expected Calibration Error). A metric that measures how well a model's confidence scores align with its actual accuracy. Lower is better. Mailwoman's held-out ECE drops from 0.067 (raw) to 0.0035 (calibrated). will be reached at a shorter character count for CJK. - Only the FTS 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. backend. The candidate-table backend (
$MAILWOMAN_CANDIDATE_DB) ranks population-first and may behave differently on Finding 5. - The 1 MB end-to-end 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. case never completed. It ran for ~2,220 s (37 minutes) and
was killed by a 2,400 s wall-clock cap without producing a result, so the defensible statement is
">37 minutes," not a figure. That is 20× the 110.6 s
computeQueryShapecost, which says 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). after query-shape also degrade badly at 60,609 segmentssegmentA punctuation-bounded chunk of the normalized input — the comma-separated parts of 'Portland, OR' — used to give downstream stages structural context. — worth its own measurement, but not one I have. The 1 MB row in Finding 2 is 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).-level number, which stands on its own. - Not a security review. SQL-injection- and FTS-syntax-shaped strings were probed for parser behaviour. Neither reached a query engine in a way this investigation examined, and no claim is made about injection safety.
- The
sizeclass in the committed fixture is truncated. Rows above ~10 KB are generated, not committed — a 1 MB JSONL line is a hostile artifact for a test suite. The generator lives inscripts/diagnostic/mailfail-probes.ts(gitignored).
The fixture
mailwoman/eval-harness/fixtures/mailfail.jsonl — 105 rows, one per probe:
{"raw": "<input>", "class": "<category>", "expect": "no-throw" | "no-component" | "no-resolve", "note": "<what it is>"}
Classes: degenerate 18, numeric 16, symbolic 14, script 21, size 3, structured 15,
adversarial 18. Bars: 35 no-throw, 35 no-component, 35 no-resolve.
expect records the bar the row should meet, not today's behaviour — 19 rows currently violate
it, and those are exactly Findings 4 and 5. Control characters, NUL and lone surrogates are
JSON-escaped; the file was verified to round-trip line-by-line through JSON.parse with every
raw byte-identical to its source.
Ranked actions
- Truncate
piecestofixedSeqLen(neural/classifier.ts:662). One line; removes both throw sites and the 500s on valid 325+ character addresses. Consider surfacing the truncationtruncationCutting an input down to the max sequence length (or an LLM response to its token limit), discarding everything past the cap. rather than performing it silently. - Add
.max()to the APIaddressfield (api/schema.ts:38,68). Closes both quadratics on the most exposed surface without touching the parser. - Two-pointer merge in
detectRegionAbbreviations(query-shape/region-abbreviations.ts:28). O(S + T), no behaviour change. - 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.-count guard in
groupPhrasesSync(phrase-grouper/group.ts:74), and correct therules.ts:778-779complexity comment. - Surface classifier faults on
PipelineResultinstead ofsafeClassify's barecatch {}. - Investigate a population floor for uncorroborated single-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. localities — measured against the fragment boardsfragment boardThe standing targeted-failure-class eval: ~400 rows per pre-registered class (homonym, bare-street, bare-locality, housenumber variants, particles, date-names) sampled from a national source, graded with Wilson intervals, with the NEGATIVE class scored (a hallucinated street on a bare locality is visible by construction). The FR board (ban-fragments-fr) is the P0 instrument; the DE board (overture-fragments-de) is the v7-fold gate's leg. first, since it trades 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. for precisionprecisionOf the spans the model labeled as a given tag, the fraction it got right. High precision means few false positives. Paired with recall to compute F1..
- Tighten
MAX_QUERY_LENfrom 512 to ~300 on the nominatim and photon drop-ins. Mitigation only; item 1 is the fix.