@mailwoman/corpus 7.2.0 → 7.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/package.json +59 -27
  2. package/src/adapter.ts +153 -0
  3. package/src/adapters/ban/adapter.ts +189 -0
  4. package/src/adapters/ban/street-decompose.ts +98 -0
  5. package/src/adapters/fcc-bdc/adapter.ts +188 -0
  6. package/src/adapters/geonames/adapter.ts +185 -0
  7. package/src/adapters/geonames-postal/adapter.ts +111 -0
  8. package/src/adapters/gnaf/adapter.ts +139 -0
  9. package/src/adapters/gnaf/assemble.ts +200 -0
  10. package/src/adapters/index.ts +135 -0
  11. package/src/adapters/openaddresses/adapter.ts +237 -0
  12. package/src/adapters/overture/adapter.ts +149 -0
  13. package/src/adapters/state-hi-schools/adapter.ts +171 -0
  14. package/src/adapters/state-ia-contractors/adapter.ts +148 -0
  15. package/src/adapters/state-ny-notaries/adapter.ts +169 -0
  16. package/src/adapters/state-tx-notaries/adapter.ts +160 -0
  17. package/src/adapters/synth-po-box/adapter.ts +197 -0
  18. package/src/adapters/tiger/adapter.ts +232 -0
  19. package/src/adapters/tiger/street-decompose.ts +120 -0
  20. package/src/adapters/usgov-hrsa-fqhc/adapter.ts +189 -0
  21. package/src/adapters/usgov-imls-pls/adapter.ts +152 -0
  22. package/src/adapters/usgov-irs-bmf/adapter.ts +155 -0
  23. package/src/adapters/usgov-nad/adapter.ts +338 -0
  24. package/src/adapters/usgov-nppes/adapter.ts +169 -0
  25. package/src/adapters/usgov-samhsa-treatment-locator/adapter.ts +211 -0
  26. package/src/adapters/wof-admin-jp/adapter.ts +176 -0
  27. package/src/adapters/wof-admin-json/adapter.ts +280 -0
  28. package/src/adapters/wof-postalcode-json/adapter.ts +209 -0
  29. package/src/align.ts +317 -0
  30. package/src/build.ts +348 -0
  31. package/src/codex/us-fips-state.ts +109 -0
  32. package/src/format.ts +14 -0
  33. package/src/golden.ts +147 -0
  34. package/src/index.ts +28 -0
  35. package/src/license.ts +48 -0
  36. package/src/parquet-wrapper/index.ts +9 -0
  37. package/src/parquet-wrapper/reader.ts +67 -0
  38. package/src/parquet-wrapper/schema.ts +80 -0
  39. package/src/parquet-wrapper/writer.ts +102 -0
  40. package/src/parquet.ts +360 -0
  41. package/src/runner.ts +283 -0
  42. package/src/shard-recipes/anchor-absorption.ts +86 -0
  43. package/src/shard-recipes/boundary-stress.ts +87 -0
  44. package/src/shard-recipes/country-balanced.ts +471 -0
  45. package/src/shard-recipes/cz-pcfirst-preposition.ts +83 -0
  46. package/src/shard-recipes/fr-admin-split.ts +212 -0
  47. package/src/shard-recipes/fr-bare-street.ts +85 -0
  48. package/src/shard-recipes/fr-fragment.ts +304 -0
  49. package/src/shard-recipes/fr-order.ts +283 -0
  50. package/src/shard-recipes/german.ts +206 -0
  51. package/src/shard-recipes/house-venue.ts +78 -0
  52. package/src/shard-recipes/index.ts +76 -0
  53. package/src/shard-recipes/intersection.ts +617 -0
  54. package/src/shard-recipes/locale.ts +415 -0
  55. package/src/shard-recipes/nl-postcode.ts +105 -0
  56. package/src/shard-recipes/no-fragment.ts +206 -0
  57. package/src/shard-recipes/no-street-led.ts +130 -0
  58. package/src/shard-recipes/no-street.ts +78 -0
  59. package/src/shard-recipes/po-box-cedex.ts +853 -0
  60. package/src/shard-recipes/po-box.ts +111 -0
  61. package/src/shard-recipes/scaffold.ts +187 -0
  62. package/src/shard-recipes/si-bare-village.ts +88 -0
  63. package/src/shard-recipes/street-affix.ts +549 -0
  64. package/src/shard-recipes/street-bare.ts +90 -0
  65. package/src/shard-recipes/street.ts +74 -0
  66. package/src/shard-recipes/unit.ts +315 -0
  67. package/src/split.ts +251 -0
  68. package/src/synthesize-anchor-absorption.ts +263 -0
  69. package/src/synthesize-boundary-stress.ts +504 -0
  70. package/src/synthesize-german.ts +227 -0
  71. package/src/synthesize-house-venue.ts +192 -0
  72. package/src/synthesize-intersection.ts +205 -0
  73. package/src/synthesize-no-street.ts +341 -0
  74. package/src/synthesize-po-box.ts +307 -0
  75. package/src/synthesize-street.ts +264 -0
  76. package/src/synthesize.ts +872 -0
  77. package/src/tokenize.ts +68 -0
  78. package/src/tools/align-shard.ts +67 -0
  79. package/src/tools/audit.ts +352 -0
  80. package/src/tools/corpus-stats.ts +183 -0
  81. package/src/tools/fetch/ban.ts +288 -0
  82. package/src/tools/fetch/download.ts +119 -0
  83. package/src/tools/fetch/hrsa.ts +65 -0
  84. package/src/tools/fetch/imls-pls.ts +162 -0
  85. package/src/tools/fetch/index.ts +121 -0
  86. package/src/tools/fetch/nad.ts +304 -0
  87. package/src/tools/fetch/nppes.ts +177 -0
  88. package/src/tools/fetch/openaddresses.ts +380 -0
  89. package/src/tools/fetch/state-hi-schools.ts +217 -0
  90. package/src/tools/fetch/state-sources.ts +142 -0
  91. package/src/tools/fetch/tiger-full.ts +318 -0
  92. package/src/tools/golden-expand.ts +524 -0
  93. package/src/tools/golden-promote.ts +304 -0
  94. package/src/tools/index.ts +24 -0
  95. package/src/tools/ingest-csv.ts +420 -0
  96. package/src/tools/jsonl-to-parquet.ts +200 -0
  97. package/src/tools/lint-shard-vocab.ts +357 -0
  98. package/src/tools/lint-shard.ts +502 -0
  99. package/src/tools/overlay-manifest.ts +141 -0
  100. package/src/tools/shard-kryptonite.ts +131 -0
  101. package/src/tools/shard-translit.ts +244 -0
  102. package/src/types.ts +213 -0
  103. package/src/wof-json.ts +236 -0
@@ -0,0 +1,341 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * No-street address synthesizer — the counter-distribution that v0.6.1's synth-street shard is
7
+ * missing. Generates BIO-labelable rows where there is NO street, NO house_number, NO
8
+ * street_prefix, NO street_suffix, NO intersection — only some subset of {venue, locality,
9
+ * region, postcode, country}.
10
+ *
11
+ * Rationale: the [2026-05-28 night-2
12
+ * postmortem](../../docs/articles/evals/night-shifts/2026-05-28-night-2-postmortem.md) and the [layer-1
13
+ * eval](../../docs/articles/evals/experiments/2026-05-28-layer-1-morphology-fst.md) showed that synth-street
14
+ * pushed the model into a high-confidence "decompose mode" that leaked into `dependent_locality`.
15
+ * Per DeepSeek's turn-2 recipe, the model needs explicit counter-examples: addresses where the
16
+ * model should NOT emit street labels. This synthesizer is that source.
17
+ *
18
+ * Six row templates, each producing a {raw, components} pair with no street-side tags:
19
+ *
20
+ * 1. **Plain venue + locality + region + postcode** `"Bob's Pizza, Boston, MA 02101"`
21
+ * 2. **Adversarial venue (containing street-typing words)** `"Wall Street Industries, NY 10005"`,
22
+ * `"5th Avenue Theater, Seattle, WA"`, `"Highway 61 Diner, Memphis TN"`. These are the rows
23
+ * that v0.6.1's decompose-mode would mis-tag as street_prefix/suffix; explicit negative
24
+ * training kills that signal.
25
+ * 3. **Locality + region + postcode (minimal)** — `"Boston, MA 02101"`
26
+ * 4. **Locality + region** — `"Boston, MA"`
27
+ * 5. **Postcode-only** — `"02101"`
28
+ * 6. **Country-only** — `"United States"`, `"France"` (rare in real data, but the model has seen these
29
+ * and should not hallucinate streets on them).
30
+ *
31
+ * Output is a `CanonicalRow` with no street-side components. Alignment will produce BIO labels
32
+ * where every token is one of {`B-venue`, `I-venue`, `B-locality`, `I-locality`, `B-region`,
33
+ * `B-postcode`, `B-country`, `I-country`, `O`} — explicitly never any street tag. That IS the
34
+ * counter-example signal the model is missing.
35
+ *
36
+ * This complements (does not replace) the existing US-base-tuple source used by
37
+ * `synthesize-po-box.ts`; the same `NoStreetBaseTuple` shape is consumed.
38
+ */
39
+
40
+ import type { CanonicalRow } from "./types.ts"
41
+
42
+ // -------------------------------------------------------------------------------------------------
43
+ // Types
44
+ // -------------------------------------------------------------------------------------------------
45
+
46
+ export interface NoStreetBaseTuple {
47
+ locality: string
48
+ region: string
49
+ postcode: string
50
+ country: string
51
+ }
52
+
53
+ export type NoStreetTemplate =
54
+ | "venue-plain"
55
+ | "venue-adversarial"
56
+ | "locality-region-postcode"
57
+ | "locality-region"
58
+ | "postcode-only"
59
+ | "country-only"
60
+
61
+ export interface NoStreetSynthesisOpts {
62
+ random?: () => number
63
+ /** Override the template selection entirely (used by tests for deterministic coverage). */
64
+ forceTemplate?: NoStreetTemplate
65
+ }
66
+
67
+ export interface SynthesizedNoStreetRow {
68
+ raw: string
69
+ components: CanonicalRow["components"]
70
+ locale: string
71
+ template: NoStreetTemplate
72
+ }
73
+
74
+ // -------------------------------------------------------------------------------------------------
75
+ // Venue name pools
76
+ // -------------------------------------------------------------------------------------------------
77
+
78
+ /**
79
+ * Plain venue names — businesses without street-typing words in the name. Used as the easy-mode positive class for
80
+ * venue detection.
81
+ */
82
+ const PLAIN_VENUES: ReadonlyArray<string> = [
83
+ "Bob's Pizza",
84
+ "Acme Corporation",
85
+ "Joe's Diner",
86
+ "Sunrise Bakery",
87
+ "Maple Leaf Cafe",
88
+ "Riverside Garden Center",
89
+ "Tech Solutions Inc",
90
+ "Pacific Industries",
91
+ "Atlantic Holdings",
92
+ "Stellar Consulting",
93
+ "Greenfield Partners",
94
+ "Mountain View Studio",
95
+ "The Daily Grind",
96
+ "Sunset Bistro",
97
+ "Harvest Moon Florist",
98
+ "Iron Forge Brewing",
99
+ "Crescent City Bookstore",
100
+ "Lighthouse Insurance Group",
101
+ "Pinecrest Veterinary",
102
+ "Westwood Realty",
103
+ ]
104
+
105
+ /**
106
+ * Adversarial venue names — businesses whose names contain street-typing tokens (Avenue, Street, Highway, Lane, Drive,
107
+ * Court, Plaza, Park, ...) but are themselves venues, not streets. The model must learn that these are venues despite
108
+ * the street-typing tokens.
109
+ *
110
+ * **No leading digit+ordinal venues** (e.g. "5th Avenue Theatre", "7th Street Bistro"). The v0.6.2 2026-05-29 step-20K
111
+ * eval showed that synthesized rows starting with `<digits><ordinal>` confused the model about house_number recognition
112
+ * — tokens like "5th" (which should be `B-house_number` in real addresses) were being labeled `B-venue` because
113
+ * adversarial venues placed them in venue position. v0.6.3 omits these patterns; the `synth-house-venue` shard
114
+ * separately teaches that house_number and venue coexist.
115
+ */
116
+ const ADVERSARIAL_VENUES: ReadonlyArray<string> = [
117
+ "Wall Street Industries",
118
+ "Highway 61 Diner",
119
+ "Lane Bryant",
120
+ "Park Avenue Dental",
121
+ "Broadway Theatre Company",
122
+ "Madison Square Garden",
123
+ "Main Street Bakery",
124
+ "Sunset Boulevard Studios",
125
+ "Ocean Drive Cafe",
126
+ "Mountain Road Outfitters",
127
+ "Hollywood Boulevard Salon",
128
+ "East Bay Auto",
129
+ "West End Pharmacy",
130
+ "North Shore Insurance",
131
+ "South Park Children's Center",
132
+ "River Road Animal Hospital",
133
+ "Hill Street Blues Bar",
134
+ "Court House Square Realty",
135
+ "Plaza Hotel",
136
+ "Lincoln Park Zoo",
137
+ "Central Park Conservancy",
138
+ "Lakeshore Boulevard Apartments",
139
+ "Memorial Drive Medical Center",
140
+ "Wabash Avenue Press",
141
+ "State Street Bank",
142
+ "Market Street Grill",
143
+ "Beach Boulevard Diner",
144
+ "Garden Lane Florist",
145
+ ]
146
+
147
+ // Compile-time guard: every venue must NOT start with the digit+ordinal pattern that
148
+ // confuses house_number recognition. If a future contributor adds a "5th Avenue Theatre"-
149
+ // style entry, this assertion will fire at module load time.
150
+ for (const v of ADVERSARIAL_VENUES) {
151
+ if (/^\d+(st|nd|rd|th)\b/i.test(v)) {
152
+ throw new Error(
153
+ `ADVERSARIAL_VENUES entry "${v}" starts with digit+ordinal; this pattern confuses ` +
154
+ `house_number recognition (see v0.6.3 eval doc). Use a non-numeric venue name.`
155
+ )
156
+ }
157
+ }
158
+
159
+ const COUNTRY_NAMES = new Map<string, ReadonlyArray<string>>([
160
+ ["US", ["United States", "USA", "U.S.A.", "United States of America"]],
161
+ ["FR", ["France"]],
162
+ ["DE", ["Germany", "Deutschland"]],
163
+ ["GB", ["United Kingdom", "UK", "Great Britain"]],
164
+ ["CA", ["Canada"]],
165
+ ["AU", ["Australia"]],
166
+ ])
167
+
168
+ // -------------------------------------------------------------------------------------------------
169
+ // Synthesis
170
+ // -------------------------------------------------------------------------------------------------
171
+
172
+ function pick<T>(arr: ReadonlyArray<T>, random: () => number): T {
173
+ return arr[Math.floor(random() * arr.length)]!
174
+ }
175
+
176
+ function countryToLocale(country: string): string {
177
+ switch (country) {
178
+ case "US":
179
+ return "en-US"
180
+ case "CA":
181
+ return "en-CA"
182
+ case "GB":
183
+ return "en-GB"
184
+ case "AU":
185
+ return "en-AU"
186
+ case "FR":
187
+ return "fr-FR"
188
+ case "DE":
189
+ return "de-DE"
190
+ default:
191
+ return "en-US"
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Generate one no-street counter-example row for a base (locality, region, postcode, country) tuple. Picks a template
197
+ * by weighted random; the venue templates are the critical counter-distribution against synth-street's decompose-mode
198
+ * pressure.
199
+ */
200
+ export function synthesizeNoStreetRow(
201
+ base: NoStreetBaseTuple,
202
+ opts: NoStreetSynthesisOpts = {}
203
+ ): SynthesizedNoStreetRow | null {
204
+ const random = opts.random ?? Math.random
205
+ const locale = countryToLocale(base.country)
206
+
207
+ const template: NoStreetTemplate = opts.forceTemplate ?? pickTemplate(random)
208
+
209
+ switch (template) {
210
+ case "venue-plain": {
211
+ const venue = pick(PLAIN_VENUES, random)
212
+ const raw = `${venue}, ${base.locality}, ${base.region} ${base.postcode}`
213
+
214
+ return {
215
+ raw,
216
+ components: {
217
+ venue,
218
+ locality: base.locality,
219
+ region: base.region,
220
+ postcode: base.postcode,
221
+ },
222
+ locale,
223
+ template,
224
+ }
225
+ }
226
+ case "venue-adversarial": {
227
+ // The venue-adversarial template name is descriptive — when selected, this branch
228
+ // always draws from the adversarial pool. The `adversarialVenueRatio` opt is what
229
+ // the OUTER template picker uses to bias toward this template versus the plain one;
230
+ // once we're inside this branch the choice is already made.
231
+ const venue = pick(ADVERSARIAL_VENUES, random)
232
+ const raw = `${venue}, ${base.locality}, ${base.region} ${base.postcode}`
233
+
234
+ return {
235
+ raw,
236
+ components: {
237
+ venue,
238
+ locality: base.locality,
239
+ region: base.region,
240
+ postcode: base.postcode,
241
+ },
242
+ locale,
243
+ template,
244
+ }
245
+ }
246
+ case "locality-region-postcode": {
247
+ const raw = `${base.locality}, ${base.region} ${base.postcode}`
248
+
249
+ return {
250
+ raw,
251
+ components: {
252
+ locality: base.locality,
253
+ region: base.region,
254
+ postcode: base.postcode,
255
+ },
256
+ locale,
257
+ template,
258
+ }
259
+ }
260
+ case "locality-region": {
261
+ const raw = `${base.locality}, ${base.region}`
262
+
263
+ return {
264
+ raw,
265
+ components: {
266
+ locality: base.locality,
267
+ region: base.region,
268
+ },
269
+ locale,
270
+ template,
271
+ }
272
+ }
273
+ case "postcode-only": {
274
+ return {
275
+ raw: base.postcode,
276
+ components: { postcode: base.postcode },
277
+ locale,
278
+ template,
279
+ }
280
+ }
281
+ case "country-only": {
282
+ const names = COUNTRY_NAMES.get(base.country) ?? [base.country]
283
+ const country = pick(names, random)
284
+
285
+ return {
286
+ raw: country,
287
+ components: { country },
288
+ locale,
289
+ template,
290
+ }
291
+ }
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Template weights chosen so that the venue-* templates dominate (they're the counter-example shape that matters), with
297
+ * the minimal templates as long-tail noise.
298
+ */
299
+ function pickTemplate(random: () => number): NoStreetTemplate {
300
+ const r = random()
301
+
302
+ if (r < 0.35) return "venue-adversarial"
303
+
304
+ // 35% — the critical slice
305
+ if (r < 0.6) return "venue-plain"
306
+
307
+ // 25%
308
+ if (r < 0.8) return "locality-region-postcode"
309
+
310
+ // 20%
311
+ if (r < 0.92) return "locality-region"
312
+
313
+ // 12%
314
+ if (r < 0.98) return "postcode-only"
315
+
316
+ // 6%
317
+ return "country-only" // 2%
318
+ }
319
+
320
+ /**
321
+ * Convenience: assert at type-level that a synthesized row carries no street-side components. Used by tests +
322
+ * downstream consumers who want to verify the contract at runtime.
323
+ */
324
+ export const STREET_SIDE_TAGS = [
325
+ "street",
326
+ "street_prefix",
327
+ "street_prefix_particle",
328
+ "street_suffix",
329
+ "house_number",
330
+ "intersection_a",
331
+ "intersection_b",
332
+ "unit",
333
+ ] as const
334
+
335
+ export function hasAnyStreetSideTag(components: CanonicalRow["components"]): boolean {
336
+ for (const t of STREET_SIDE_TAGS) {
337
+ if (components[t]) return true
338
+ }
339
+
340
+ return false
341
+ }
@@ -0,0 +1,307 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * PO box / PMB / Apartado / Boîte Postale synthesizer.
7
+ *
8
+ * Generates BIO-labeled corpus rows where the delivery line is a PO box (mutually exclusive with
9
+ * street + house_number per USPS Pub 28 / DMM 508). Locale-aware: emits idiomatic forms for
10
+ * en-US, en-CA, en-GB, en-AU, fr-FR, fr-CA, es-ES, es-MX, es-AR.
11
+ *
12
+ * Per-DeepSeek design:
13
+ *
14
+ * - PMB ("Private Mailbox" — at CMRAs like UPS Store) shares the `po_box` tag with USPS PO Box.
15
+ * Disambiguation is a downstream heuristic (presence of a street line).
16
+ * - Whole-phrase span ("PO Box 123") not number-only ("123"). Matches existing golden eval.
17
+ * - 10% of outputs receive number-format noise (commas, dashes, embedded spaces) to harden against
18
+ * real-world OCR/transcription input.
19
+ * - PO boxes drop street/house_number/unit/street_prefix/street_suffix from input components.
20
+ *
21
+ * References:
22
+ *
23
+ * - USPS Pub 28 §28C2.040 — Private Mailbox formatting
24
+ * - USPS DMM 508 §4.1.4 / §4.5.4 — PO Box and street-addressed PO Box
25
+ */
26
+
27
+ import type { CanonicalRow } from "./types.ts"
28
+
29
+ export interface PoBoxBaseTuple {
30
+ locality: string
31
+ region: string
32
+ postcode: string
33
+ country: string
34
+ }
35
+
36
+ export interface LocaleTemplate {
37
+ locale: string
38
+ leaders: ReadonlyArray<string>
39
+ // Use 'pmb' to render as "STREET, PMB N, CITY ..." instead of replacing the street line.
40
+ pmb?: ReadonlyArray<string>
41
+ }
42
+
43
+ /**
44
+ * The per-locale PO-box designator vocabulary (DeepSeek-signed list, see the header). Exported so shard builders
45
+ * (scripts/build-po-box-cedex-shard.mjs) can reuse THIS list as the single source of truth for non-US leaders instead
46
+ * of re-deriving it — the US slice additionally has `@mailwoman/codex/us` `US_PO_BOX_DESIGNATORS`/`isPOBox` as its
47
+ * matcher-side truth.
48
+ */
49
+ export const PO_BOX_LOCALE_TEMPLATES: ReadonlyArray<LocaleTemplate> = [
50
+ {
51
+ locale: "en-US",
52
+ leaders: ["PO Box", "P.O. Box", "P.O.Box", "PO BOX", "POB", "Post Office Box", "Box"],
53
+ pmb: ["PMB", "#"],
54
+ },
55
+ {
56
+ locale: "en-CA",
57
+ leaders: ["PO Box", "P.O. Box", "POB", "Post Office Box"],
58
+ pmb: ["PMB", "#"],
59
+ },
60
+ {
61
+ locale: "en-GB",
62
+ leaders: ["PO Box", "P.O. Box", "Post Office Box"],
63
+ },
64
+ {
65
+ locale: "en-AU",
66
+ leaders: ["PO Box", "P.O. Box", "Post Office Box", "GPO Box", "Locked Bag"],
67
+ },
68
+ {
69
+ locale: "en-NZ",
70
+ leaders: ["PO Box", "P.O. Box", "Post Office Box", "Private Bag", "Private Box"],
71
+ },
72
+ {
73
+ locale: "fr-FR",
74
+ leaders: ["BP", "B.P.", "Boîte Postale", "BP."],
75
+ },
76
+ {
77
+ locale: "fr-CA",
78
+ leaders: ["CP", "C.P.", "Case Postale", "BP", "B.P."],
79
+ },
80
+ {
81
+ locale: "es-ES",
82
+ leaders: ["Apdo.", "Apdo", "Apartado", "Apartado de Correos"],
83
+ },
84
+ {
85
+ locale: "es-MX",
86
+ leaders: ["Apdo.", "Apartado", "Apartado Postal", "AP"],
87
+ },
88
+ {
89
+ locale: "es-AR",
90
+ leaders: ["Casilla", "Casilla de Correo", "CC"],
91
+ },
92
+ ]
93
+
94
+ const LEADERS_BY_LOCALE = new Map<string, LocaleTemplate>(PO_BOX_LOCALE_TEMPLATES.map((t) => [t.locale, t]))
95
+
96
+ /**
97
+ * Inject number-format noise into a box number string. Returns the noisy variant or the original (10% probability of
98
+ * noise per the design).
99
+ */
100
+ export function maybeNoisifyBoxNumber(num: string, random: () => number): string {
101
+ if (random() > 0.1) return num
102
+ const variants: Array<(s: string) => string> = [
103
+ // Thousand-separator comma (real input: "Box 1,234")
104
+ (s) => (s.length >= 4 ? `${s.slice(0, -3)},${s.slice(-3)}` : s),
105
+ // Embedded dash (real input: "PMB-200")
106
+ (s) => (s.length >= 3 ? `${s.slice(0, -2)}-${s.slice(-2)}` : s),
107
+ // Embedded spaces (real input from OCR: "1 2 3 4")
108
+ (s) => s.split("").join(" "),
109
+ ]
110
+ const f = variants[Math.floor(random() * variants.length)]!
111
+
112
+ return f(num)
113
+ }
114
+
115
+ /**
116
+ * Compose a PO box phrase like "PO Box 123" or "PMB 200".
117
+ *
118
+ * Returns both the phrase and the canonical leader+number so the BIO aligner can mark the entire span as `po_box`.
119
+ */
120
+ export function composePoBoxPhrase(leader: string, number: string): string {
121
+ return `${leader} ${number}`
122
+ }
123
+
124
+ export interface SynthesizedPoBoxRow {
125
+ raw: string
126
+ components: CanonicalRow["components"]
127
+ locale: string
128
+ template: "po-box" | "pmb-with-street" | "military-po-box"
129
+ }
130
+
131
+ export interface PoBoxSynthesisOpts {
132
+ /** Random function — pass deterministic seed for tests. Default Math.random. */
133
+ random?: () => number
134
+ /** Number generator. Default uniform over 1..99999. */
135
+ pickNumber?: (random: () => number) => string
136
+ /** PMB probability when locale supports it (and a street is provided in the base tuple). */
137
+ pmbRatio?: number
138
+ }
139
+
140
+ function defaultPickNumber(random: () => number): string {
141
+ // 70% of real PO boxes are 1-5 digits; long ones exist (USPS allows up to ~6 digits).
142
+ const r = random()
143
+
144
+ if (r < 0.3) return String(1 + Math.floor(random() * 99))
145
+
146
+ // 1-99
147
+ if (r < 0.7) return String(100 + Math.floor(random() * 900))
148
+
149
+ // 100-999
150
+ if (r < 0.95) return String(1000 + Math.floor(random() * 9000))
151
+
152
+ // 1000-9999
153
+ return String(10000 + Math.floor(random() * 90000)) // 10000-99999
154
+ }
155
+
156
+ /**
157
+ * Generate one PO box row for a base (locality, region, postcode, country) tuple. Picks a locale-appropriate leader and
158
+ * number. Optionally generates a PMB variant when the base tuple includes a street.
159
+ */
160
+ export function synthesizePoBoxRow(
161
+ base: PoBoxBaseTuple & { street?: string; houseNumber?: string },
162
+ opts: PoBoxSynthesisOpts = {}
163
+ ): SynthesizedPoBoxRow | null {
164
+ const random = opts.random ?? Math.random
165
+ const pickNumber = opts.pickNumber ?? defaultPickNumber
166
+ const pmbRatio = opts.pmbRatio ?? 0.0
167
+
168
+ const locale = countryToLocale(base.country)
169
+ const tpl = LEADERS_BY_LOCALE.get(locale)
170
+
171
+ if (!tpl) return null
172
+
173
+ const number = maybeNoisifyBoxNumber(pickNumber(random), random)
174
+ const leader = tpl.leaders[Math.floor(random() * tpl.leaders.length)]!
175
+ const poBoxPhrase = composePoBoxPhrase(leader, number)
176
+
177
+ // PMB variant: requires both a street and a PMB-supporting locale.
178
+ const wantPmb = base.street && tpl.pmb && random() < pmbRatio
179
+
180
+ if (wantPmb) {
181
+ const pmbLeader = tpl.pmb![Math.floor(random() * tpl.pmb!.length)]!
182
+ const pmbPhrase = composePoBoxPhrase(pmbLeader, number)
183
+ const streetLine = base.houseNumber ? `${base.houseNumber} ${base.street}` : base.street!
184
+ const raw = `${streetLine}, ${pmbPhrase}, ${base.locality}, ${base.region} ${base.postcode}`
185
+
186
+ return {
187
+ raw,
188
+ components: {
189
+ ...(base.houseNumber ? { house_number: base.houseNumber } : {}),
190
+ street: base.street!,
191
+ po_box: pmbPhrase,
192
+ locality: base.locality,
193
+ region: base.region,
194
+ postcode: base.postcode,
195
+ country: base.country,
196
+ },
197
+ locale,
198
+ template: "pmb-with-street",
199
+ }
200
+ }
201
+
202
+ // Standard PO box: replaces the street line entirely. Region-optional — NZ (and other region-less
203
+ // locales) read "Private Bag 12, Auckland 1010" with no region token between locality and postcode.
204
+ const hasRegion = Boolean(base.region && base.region.trim())
205
+ const tail = hasRegion ? `${base.locality}, ${base.region} ${base.postcode}` : `${base.locality} ${base.postcode}`
206
+ const raw = `${poBoxPhrase}, ${tail}`
207
+
208
+ return {
209
+ raw,
210
+ components: {
211
+ po_box: poBoxPhrase,
212
+ locality: base.locality,
213
+ ...(hasRegion ? { region: base.region } : {}),
214
+ postcode: base.postcode,
215
+ country: base.country,
216
+ },
217
+ locale,
218
+ template: "po-box",
219
+ }
220
+ }
221
+
222
+ /**
223
+ * The US military/diplomatic PO-box class (#517). A distinct shape the leader-based locale templates can't express: a
224
+ * unit line (`PSC <id> Box <box>`, `CMR <id> Box <box>`, `Unit <id> [Box <box>]`) tagged `po_box`, then the post-office
225
+ * code (APO/FPO/DPO) as the locality and the armed-forces region (AA/AE/AP) as the region, with a theatre-specific ZIP.
226
+ * Authoritative reference + citations: `@mailwoman/codex` `codex/us/military-address.ts`; the small constants are
227
+ * inlined here so the generator is self-contained.
228
+ */
229
+ const MIL_UNITS: ReadonlyArray<{ code: string; boxRequired: boolean }> = [
230
+ { code: "PSC", boxRequired: true },
231
+ { code: "CMR", boxRequired: true },
232
+ { code: "Unit", boxRequired: false },
233
+ ]
234
+ const MIL_PO_CODES = ["APO", "FPO", "DPO"] as const
235
+ // region → plausible ZIP prefix (AE Europe 09xxx, AP Pacific 962-966xx, AA Americas 340xx).
236
+ const MIL_REGION_ZIP: ReadonlyArray<{ region: string; zip: (r: () => number) => string }> = [
237
+ { region: "AE", zip: (r) => `09${String(Math.floor(r() * 1000)).padStart(3, "0")}` },
238
+ { region: "AP", zip: (r) => `96${String(200 + Math.floor(r() * 100)).padStart(3, "0")}` },
239
+ { region: "AA", zip: (r) => `340${String(Math.floor(r() * 100)).padStart(2, "0")}` },
240
+ ]
241
+
242
+ /** Generate one US military/diplomatic PO-box row (#517). Self-contained — draws no base tuple. */
243
+ export function synthesizeMilitaryPoBoxRow(opts: PoBoxSynthesisOpts = {}): SynthesizedPoBoxRow {
244
+ const random = opts.random ?? Math.random
245
+ const unit = MIL_UNITS[Math.floor(random() * MIL_UNITS.length)]!
246
+ const unitID = String(1 + Math.floor(random() * 9999))
247
+ const { region, zip } = MIL_REGION_ZIP[Math.floor(random() * MIL_REGION_ZIP.length)]!
248
+ const zipStr = zip(random)
249
+ const po = MIL_PO_CODES[Math.floor(random() * MIL_PO_CODES.length)]!
250
+ const hasBox = unit.boxRequired || random() < 0.5
251
+ const unitLine = hasBox ? `${unit.code} ${unitID} Box ${1 + Math.floor(random() * 9999)}` : `${unit.code} ${unitID}`
252
+ const raw = `${unitLine}, ${po} ${region} ${zipStr}`
253
+
254
+ return {
255
+ raw,
256
+ components: {
257
+ po_box: unitLine,
258
+ locality: po,
259
+ region,
260
+ postcode: zipStr,
261
+ country: "US",
262
+ },
263
+ locale: "en-US",
264
+ template: "military-po-box",
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Map a country code (ISO-3166-1 alpha-2 or alpha-3, or country display name) to the locale code we have a PO box
270
+ * template for.
271
+ */
272
+ export function countryToLocale(country: string): string {
273
+ const c = country.trim().toUpperCase()
274
+
275
+ if (c === "US" || c === "USA" || c === "UNITED STATES") return "en-US"
276
+
277
+ if (c === "CA" || c === "CAN" || c === "CANADA") return "en-CA"
278
+
279
+ if (c === "GB" || c === "UK" || c === "GBR" || c === "UNITED KINGDOM") return "en-GB"
280
+
281
+ if (c === "AU" || c === "AUS" || c === "AUSTRALIA") return "en-AU"
282
+
283
+ if (c === "NZ" || c === "NZL" || c === "NEW ZEALAND") return "en-NZ"
284
+
285
+ if (c === "FR" || c === "FRA" || c === "FRANCE") return "fr-FR"
286
+
287
+ if (c === "ES" || c === "ESP" || c === "SPAIN") return "es-ES"
288
+
289
+ if (c === "MX" || c === "MEX" || c === "MEXICO") return "es-MX"
290
+
291
+ if (c === "AR" || c === "ARG" || c === "ARGENTINA") return "es-AR"
292
+
293
+ return "en-US"
294
+ }
295
+
296
+ /** All locales we synthesize for. Exposed for tests and for source-weight tuning. */
297
+ export function supportedLocales(): ReadonlyArray<string> {
298
+ return PO_BOX_LOCALE_TEMPLATES.map((t) => t.locale)
299
+ }
300
+
301
+ /**
302
+ * Locales whose standard PO-box delivery line carries NO region token — the address reads `<po_box>, <locality>
303
+ * <postcode>` with nothing between locality and postcode (#517). NZ is the canonical case (`Private Bag 12, Auckland
304
+ * 1010`). Consumers (e.g. the synth-po-box adapter) use this to avoid discarding region-less input tuples for these
305
+ * locales as "missing region".
306
+ */
307
+ export const REGION_OPTIONAL_LOCALES: ReadonlySet<string> = new Set(["en-NZ"])