@mailwoman/codex 9.0.0 → 9.2.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 (96) hide show
  1. package/README.md +48 -5
  2. package/ca/index.ts +1 -1
  3. package/ca/postal-code.ts +11 -11
  4. package/country/display-names.ts +150 -0
  5. package/country/index.ts +2 -0
  6. package/country/population.ts +265 -0
  7. package/country/subdivision.ts +59 -0
  8. package/de/index.ts +1 -1
  9. package/de/postleitzahl.ts +3 -3
  10. package/es/codigo-postal.ts +2 -2
  11. package/fr/code-postal.ts +6 -6
  12. package/fr/index.ts +1 -1
  13. package/index.ts +10 -1
  14. package/it/cap.ts +2 -2
  15. package/jp/index.ts +1 -1
  16. package/jp/postal-code.ts +11 -11
  17. package/out/ca/index.d.ts +1 -1
  18. package/out/ca/index.js +1 -1
  19. package/out/ca/postal-code.d.ts +10 -10
  20. package/out/ca/postal-code.js +9 -9
  21. package/out/country/display-names.d.ts +67 -0
  22. package/out/country/display-names.d.ts.map +1 -0
  23. package/out/country/display-names.js +118 -0
  24. package/out/country/display-names.js.map +1 -0
  25. package/out/country/index.d.ts +2 -0
  26. package/out/country/index.d.ts.map +1 -1
  27. package/out/country/index.js +2 -0
  28. package/out/country/index.js.map +1 -1
  29. package/out/country/population.d.ts +16 -0
  30. package/out/country/population.d.ts.map +1 -0
  31. package/out/country/population.js +265 -0
  32. package/out/country/population.js.map +1 -0
  33. package/out/country/subdivision.d.ts +7 -0
  34. package/out/country/subdivision.d.ts.map +1 -1
  35. package/out/country/subdivision.js +49 -0
  36. package/out/country/subdivision.js.map +1 -1
  37. package/out/de/index.d.ts +1 -1
  38. package/out/de/index.js +1 -1
  39. package/out/de/postleitzahl.d.ts +3 -3
  40. package/out/de/postleitzahl.js +2 -2
  41. package/out/es/codigo-postal.d.ts +2 -2
  42. package/out/es/codigo-postal.js +1 -1
  43. package/out/fr/code-postal.d.ts +5 -5
  44. package/out/fr/code-postal.js +6 -6
  45. package/out/fr/index.d.ts +1 -1
  46. package/out/fr/index.js +1 -1
  47. package/out/index.d.ts +1 -1
  48. package/out/index.d.ts.map +1 -1
  49. package/out/index.js +1 -1
  50. package/out/index.js.map +1 -1
  51. package/out/it/cap.d.ts +2 -2
  52. package/out/it/cap.js +1 -1
  53. package/out/jp/index.d.ts +1 -1
  54. package/out/jp/index.js +1 -1
  55. package/out/jp/postal-code.d.ts +11 -11
  56. package/out/jp/postal-code.js +8 -8
  57. package/out/postcode-systems.d.ts +84 -0
  58. package/out/postcode-systems.d.ts.map +1 -1
  59. package/out/postcode-systems.js +106 -0
  60. package/out/postcode-systems.js.map +1 -1
  61. package/out/tools/build-country-surface-lexicon.js +1 -1
  62. package/out/tools/build-country-surface-lexicon.js.map +1 -1
  63. package/out/tools/export-country-surfaces.js +1 -1
  64. package/out/tools/export-country-surfaces.js.map +1 -1
  65. package/out/tools/generate-country-population.d.ts +42 -0
  66. package/out/tools/generate-country-population.d.ts.map +1 -0
  67. package/out/tools/generate-country-population.js +106 -0
  68. package/out/tools/generate-country-population.js.map +1 -0
  69. package/out/tools/generate-country-reference.d.ts.map +1 -1
  70. package/out/tools/generate-country-reference.js +4 -4
  71. package/out/tools/generate-country-reference.js.map +1 -1
  72. package/out/tools/generate-official-languages.d.ts.map +1 -1
  73. package/out/tools/generate-official-languages.js +7 -4
  74. package/out/tools/generate-official-languages.js.map +1 -1
  75. package/out/tools/index.d.ts +2 -2
  76. package/out/tools/index.d.ts.map +1 -1
  77. package/out/tools/index.js +2 -2
  78. package/out/tools/index.js.map +1 -1
  79. package/out/us/street-suffix.d.ts +432 -418
  80. package/out/us/street-suffix.d.ts.map +1 -1
  81. package/out/us/street-suffix.js +28 -216
  82. package/out/us/street-suffix.js.map +1 -1
  83. package/out/us/street-suffix.json +273 -0
  84. package/out/us/zipcode.d.ts +1 -1
  85. package/out/us/zipcode.js +1 -1
  86. package/package.json +328 -4
  87. package/postcode-systems.ts +112 -0
  88. package/tools/build-country-surface-lexicon.ts +1 -1
  89. package/tools/export-country-surfaces.ts +1 -1
  90. package/tools/generate-country-population.ts +143 -0
  91. package/tools/generate-country-reference.ts +5 -3
  92. package/tools/generate-official-languages.ts +8 -4
  93. package/tools/index.ts +2 -2
  94. package/us/street-suffix.json +273 -0
  95. package/us/street-suffix.ts +32 -217
  96. package/us/zipcode.ts +1 -1
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@mailwoman/codex",
3
- "version": "9.0.0",
3
+ "version": "9.2.0",
4
4
  "description": "Per-address-system postal reference data + branded types (USPS street suffixes, US ZIP codes). Pure, zero-runtime-dep — the shared canonical home for postal-system primitives the parser, resolver, and synthesis layers all reach for.",
5
5
  "license": "AGPL-3.0-only OR LicenseRef-Commercial",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "https://github.com/sister-software/mailwoman.git",
9
- "directory": "codex"
9
+ "directory": "packages/codex"
10
10
  },
11
11
  "files": [
12
12
  "out/**/*.js",
@@ -20,7 +20,10 @@
20
20
  "!*.test.ts",
21
21
  "!*.test.tsx",
22
22
  "!**/*.test.ts",
23
- "!**/*.test.tsx"
23
+ "!**/*.test.tsx",
24
+ "us/street-suffix.json",
25
+ "out/us/street-suffix.json",
26
+ "!test/**"
24
27
  ],
25
28
  "type": "module",
26
29
  "exports": {
@@ -76,6 +79,166 @@
76
79
  "./it": {
77
80
  "types": "./out/it/index.d.ts",
78
81
  "default": "./out/it/index.js"
82
+ },
83
+ "./address-system-conventions": {
84
+ "types": "./out/address-system-conventions.d.ts",
85
+ "default": "./out/address-system-conventions.js"
86
+ },
87
+ "./gb/postcode": {
88
+ "types": "./out/gb/postcode.d.ts",
89
+ "default": "./out/gb/postcode.js"
90
+ },
91
+ "./level-semantics": {
92
+ "types": "./out/level-semantics.d.ts",
93
+ "default": "./out/level-semantics.js"
94
+ },
95
+ "./postcode-systems": {
96
+ "types": "./out/postcode-systems.d.ts",
97
+ "default": "./out/postcode-systems.js"
98
+ },
99
+ "./au/delivery-service": {
100
+ "types": "./out/au/delivery-service.d.ts",
101
+ "default": "./out/au/delivery-service.js"
102
+ },
103
+ "./au/level-designator": {
104
+ "types": "./out/au/level-designator.d.ts",
105
+ "default": "./out/au/level-designator.js"
106
+ },
107
+ "./au/postcode": {
108
+ "types": "./out/au/postcode.d.ts",
109
+ "default": "./out/au/postcode.js"
110
+ },
111
+ "./au/state": {
112
+ "types": "./out/au/state.d.ts",
113
+ "default": "./out/au/state.js"
114
+ },
115
+ "./ca/postal-code": {
116
+ "types": "./out/ca/postal-code.d.ts",
117
+ "default": "./out/ca/postal-code.js"
118
+ },
119
+ "./ca/province": {
120
+ "types": "./out/ca/province.d.ts",
121
+ "default": "./out/ca/province.js"
122
+ },
123
+ "./ca/street-type": {
124
+ "types": "./out/ca/street-type.d.ts",
125
+ "default": "./out/ca/street-type.js"
126
+ },
127
+ "./country/country": {
128
+ "types": "./out/country/country.d.ts",
129
+ "default": "./out/country/country.js"
130
+ },
131
+ "./country/display-names": {
132
+ "types": "./out/country/display-names.d.ts",
133
+ "default": "./out/country/display-names.js"
134
+ },
135
+ "./country/official-languages": {
136
+ "types": "./out/country/official-languages.d.ts",
137
+ "default": "./out/country/official-languages.js"
138
+ },
139
+ "./country/reference": {
140
+ "types": "./out/country/reference.d.ts",
141
+ "default": "./out/country/reference.js"
142
+ },
143
+ "./country/subdivision": {
144
+ "types": "./out/country/subdivision.d.ts",
145
+ "default": "./out/country/subdivision.js"
146
+ },
147
+ "./de/bundesland": {
148
+ "types": "./out/de/bundesland.d.ts",
149
+ "default": "./out/de/bundesland.js"
150
+ },
151
+ "./de/postleitzahl": {
152
+ "types": "./out/de/postleitzahl.d.ts",
153
+ "default": "./out/de/postleitzahl.js"
154
+ },
155
+ "./de/street-type": {
156
+ "types": "./out/de/street-type.d.ts",
157
+ "default": "./out/de/street-type.js"
158
+ },
159
+ "./fr/cedex": {
160
+ "types": "./out/fr/cedex.d.ts",
161
+ "default": "./out/fr/cedex.js"
162
+ },
163
+ "./fr/code-postal": {
164
+ "types": "./out/fr/code-postal.d.ts",
165
+ "default": "./out/fr/code-postal.js"
166
+ },
167
+ "./fr/departement": {
168
+ "types": "./out/fr/departement.d.ts",
169
+ "default": "./out/fr/departement.js"
170
+ },
171
+ "./fr/region": {
172
+ "types": "./out/fr/region.d.ts",
173
+ "default": "./out/fr/region.js"
174
+ },
175
+ "./fr/voie": {
176
+ "types": "./out/fr/voie.d.ts",
177
+ "default": "./out/fr/voie.js"
178
+ },
179
+ "./gb/country": {
180
+ "types": "./out/gb/country.d.ts",
181
+ "default": "./out/gb/country.js"
182
+ },
183
+ "./gb/postcode-area": {
184
+ "types": "./out/gb/postcode-area.d.ts",
185
+ "default": "./out/gb/postcode-area.js"
186
+ },
187
+ "./gb/street-type": {
188
+ "types": "./out/gb/street-type.d.ts",
189
+ "default": "./out/gb/street-type.js"
190
+ },
191
+ "./jp/address-unit": {
192
+ "types": "./out/jp/address-unit.d.ts",
193
+ "default": "./out/jp/address-unit.js"
194
+ },
195
+ "./jp/postal-code": {
196
+ "types": "./out/jp/postal-code.d.ts",
197
+ "default": "./out/jp/postal-code.js"
198
+ },
199
+ "./jp/prefecture": {
200
+ "types": "./out/jp/prefecture.d.ts",
201
+ "default": "./out/jp/prefecture.js"
202
+ },
203
+ "./nz/delivery-service": {
204
+ "types": "./out/nz/delivery-service.d.ts",
205
+ "default": "./out/nz/delivery-service.js"
206
+ },
207
+ "./nz/postcode": {
208
+ "types": "./out/nz/postcode.d.ts",
209
+ "default": "./out/nz/postcode.js"
210
+ },
211
+ "./us/floor-designator": {
212
+ "types": "./out/us/floor-designator.d.ts",
213
+ "default": "./out/us/floor-designator.js"
214
+ },
215
+ "./us/military-address": {
216
+ "types": "./out/us/military-address.d.ts",
217
+ "default": "./out/us/military-address.js"
218
+ },
219
+ "./us/po-box": {
220
+ "types": "./out/us/po-box.d.ts",
221
+ "default": "./out/us/po-box.js"
222
+ },
223
+ "./us/state": {
224
+ "types": "./out/us/state.d.ts",
225
+ "default": "./out/us/state.js"
226
+ },
227
+ "./us/street-directional": {
228
+ "types": "./out/us/street-directional.d.ts",
229
+ "default": "./out/us/street-directional.js"
230
+ },
231
+ "./us/street-suffix": {
232
+ "types": "./out/us/street-suffix.d.ts",
233
+ "default": "./out/us/street-suffix.js"
234
+ },
235
+ "./us/unit-designator": {
236
+ "types": "./out/us/unit-designator.d.ts",
237
+ "default": "./out/us/unit-designator.js"
238
+ },
239
+ "./us/zipcode": {
240
+ "types": "./out/us/zipcode.d.ts",
241
+ "default": "./out/us/zipcode.js"
79
242
  }
80
243
  },
81
244
  "publishConfig": {
@@ -133,6 +296,166 @@
133
296
  "./it": {
134
297
  "types": "./out/it/index.d.ts",
135
298
  "default": "./out/it/index.js"
299
+ },
300
+ "./address-system-conventions": {
301
+ "types": "./out/address-system-conventions.d.ts",
302
+ "default": "./out/address-system-conventions.js"
303
+ },
304
+ "./gb/postcode": {
305
+ "types": "./out/gb/postcode.d.ts",
306
+ "default": "./out/gb/postcode.js"
307
+ },
308
+ "./level-semantics": {
309
+ "types": "./out/level-semantics.d.ts",
310
+ "default": "./out/level-semantics.js"
311
+ },
312
+ "./postcode-systems": {
313
+ "types": "./out/postcode-systems.d.ts",
314
+ "default": "./out/postcode-systems.js"
315
+ },
316
+ "./au/delivery-service": {
317
+ "types": "./out/au/delivery-service.d.ts",
318
+ "default": "./out/au/delivery-service.js"
319
+ },
320
+ "./au/level-designator": {
321
+ "types": "./out/au/level-designator.d.ts",
322
+ "default": "./out/au/level-designator.js"
323
+ },
324
+ "./au/postcode": {
325
+ "types": "./out/au/postcode.d.ts",
326
+ "default": "./out/au/postcode.js"
327
+ },
328
+ "./au/state": {
329
+ "types": "./out/au/state.d.ts",
330
+ "default": "./out/au/state.js"
331
+ },
332
+ "./ca/postal-code": {
333
+ "types": "./out/ca/postal-code.d.ts",
334
+ "default": "./out/ca/postal-code.js"
335
+ },
336
+ "./ca/province": {
337
+ "types": "./out/ca/province.d.ts",
338
+ "default": "./out/ca/province.js"
339
+ },
340
+ "./ca/street-type": {
341
+ "types": "./out/ca/street-type.d.ts",
342
+ "default": "./out/ca/street-type.js"
343
+ },
344
+ "./country/country": {
345
+ "types": "./out/country/country.d.ts",
346
+ "default": "./out/country/country.js"
347
+ },
348
+ "./country/display-names": {
349
+ "types": "./out/country/display-names.d.ts",
350
+ "default": "./out/country/display-names.js"
351
+ },
352
+ "./country/official-languages": {
353
+ "types": "./out/country/official-languages.d.ts",
354
+ "default": "./out/country/official-languages.js"
355
+ },
356
+ "./country/reference": {
357
+ "types": "./out/country/reference.d.ts",
358
+ "default": "./out/country/reference.js"
359
+ },
360
+ "./country/subdivision": {
361
+ "types": "./out/country/subdivision.d.ts",
362
+ "default": "./out/country/subdivision.js"
363
+ },
364
+ "./de/bundesland": {
365
+ "types": "./out/de/bundesland.d.ts",
366
+ "default": "./out/de/bundesland.js"
367
+ },
368
+ "./de/postleitzahl": {
369
+ "types": "./out/de/postleitzahl.d.ts",
370
+ "default": "./out/de/postleitzahl.js"
371
+ },
372
+ "./de/street-type": {
373
+ "types": "./out/de/street-type.d.ts",
374
+ "default": "./out/de/street-type.js"
375
+ },
376
+ "./fr/cedex": {
377
+ "types": "./out/fr/cedex.d.ts",
378
+ "default": "./out/fr/cedex.js"
379
+ },
380
+ "./fr/code-postal": {
381
+ "types": "./out/fr/code-postal.d.ts",
382
+ "default": "./out/fr/code-postal.js"
383
+ },
384
+ "./fr/departement": {
385
+ "types": "./out/fr/departement.d.ts",
386
+ "default": "./out/fr/departement.js"
387
+ },
388
+ "./fr/region": {
389
+ "types": "./out/fr/region.d.ts",
390
+ "default": "./out/fr/region.js"
391
+ },
392
+ "./fr/voie": {
393
+ "types": "./out/fr/voie.d.ts",
394
+ "default": "./out/fr/voie.js"
395
+ },
396
+ "./gb/country": {
397
+ "types": "./out/gb/country.d.ts",
398
+ "default": "./out/gb/country.js"
399
+ },
400
+ "./gb/postcode-area": {
401
+ "types": "./out/gb/postcode-area.d.ts",
402
+ "default": "./out/gb/postcode-area.js"
403
+ },
404
+ "./gb/street-type": {
405
+ "types": "./out/gb/street-type.d.ts",
406
+ "default": "./out/gb/street-type.js"
407
+ },
408
+ "./jp/address-unit": {
409
+ "types": "./out/jp/address-unit.d.ts",
410
+ "default": "./out/jp/address-unit.js"
411
+ },
412
+ "./jp/postal-code": {
413
+ "types": "./out/jp/postal-code.d.ts",
414
+ "default": "./out/jp/postal-code.js"
415
+ },
416
+ "./jp/prefecture": {
417
+ "types": "./out/jp/prefecture.d.ts",
418
+ "default": "./out/jp/prefecture.js"
419
+ },
420
+ "./nz/delivery-service": {
421
+ "types": "./out/nz/delivery-service.d.ts",
422
+ "default": "./out/nz/delivery-service.js"
423
+ },
424
+ "./nz/postcode": {
425
+ "types": "./out/nz/postcode.d.ts",
426
+ "default": "./out/nz/postcode.js"
427
+ },
428
+ "./us/floor-designator": {
429
+ "types": "./out/us/floor-designator.d.ts",
430
+ "default": "./out/us/floor-designator.js"
431
+ },
432
+ "./us/military-address": {
433
+ "types": "./out/us/military-address.d.ts",
434
+ "default": "./out/us/military-address.js"
435
+ },
436
+ "./us/po-box": {
437
+ "types": "./out/us/po-box.d.ts",
438
+ "default": "./out/us/po-box.js"
439
+ },
440
+ "./us/state": {
441
+ "types": "./out/us/state.d.ts",
442
+ "default": "./out/us/state.js"
443
+ },
444
+ "./us/street-directional": {
445
+ "types": "./out/us/street-directional.d.ts",
446
+ "default": "./out/us/street-directional.js"
447
+ },
448
+ "./us/street-suffix": {
449
+ "types": "./out/us/street-suffix.d.ts",
450
+ "default": "./out/us/street-suffix.js"
451
+ },
452
+ "./us/unit-designator": {
453
+ "types": "./out/us/unit-designator.d.ts",
454
+ "default": "./out/us/unit-designator.js"
455
+ },
456
+ "./us/zipcode": {
457
+ "types": "./out/us/zipcode.d.ts",
458
+ "default": "./out/us/zipcode.js"
136
459
  }
137
460
  }
138
461
  },
@@ -140,6 +463,7 @@
140
463
  "type-fest": "^5.8.0"
141
464
  },
142
465
  "devDependencies": {
143
- "@mailwoman/annotations": "9.0.0"
466
+ "@mailwoman/annotations": "9.2.0",
467
+ "@mailwoman/core": "9.2.0"
144
468
  }
145
469
  }
@@ -68,3 +68,115 @@ export function candidateSystemsForPostcode(postcode: string): SystemCode[] {
68
68
 
69
69
  return out
70
70
  }
71
+
72
+ /**
73
+ * Postcode shapes whose code is UNIT-GRADE — a delivery-walk or street-block unit, categorically tighter than any
74
+ * locality centroid, so an EXACT hit on one may lead the admin ladder instead of following the locality-first epoch
75
+ * convention.
76
+ *
77
+ * The convention exists because most postal systems are AREA-class: an FR 5-digit zone is coarser than the commune it
78
+ * contains, so promoting it would trade a good answer for a worse one. Two systems are the other way round, and
79
+ * membership here is earned by MEASUREMENT of the code's granularity, never by "the code has letters in it":
80
+ *
81
+ * - **NL PC6** (`1012 LG`) — ~8 addresses per code; the CBS polygon centroid (#977, the original carve-out).
82
+ * - **GB unit** (`N7 0BT`) — ~15 addresses per code, 1,751,733 shipped from OS Code-Point Open. Measured 2026-08-10
83
+ * against the panel-v2 GB rooftop truth: unit centroid within 1 km on 15/15 rows, median 38 m, max 100 m, while the
84
+ * locality centroid the ladder returned instead was 5.1–14.6 km out.
85
+ * - **CA URBAN LDU** (`M1J 1A8`) — 843,739 six-character codes. Measured on 879 graded rows of the CA OSM-rooftop panel,
86
+ * through the production candidate backend, ladder arm against ladder arm:
87
+ *
88
+ * | 732 URBAN rows | p50 | p75 | p90 | ≤1 km | | -------------- | ---: | ---: | ---: | ---: | | locality-first | 2.51
89
+ * km | 5.42 km | 9.53 km | 26.4% | | postcode-first | **78 m** | **162 m** | **373 m** | **94.7%** |
90
+ *
91
+ * Closer on 90.4% of them. That is GB's tier on a sample fifty times larger than GB's.
92
+ *
93
+ * **CA RURAL is excluded, and the code says which.** Canada Post puts a `0` in the SECOND position of a rural forward
94
+ * sortation area, so `T0H 1M0` is rural and `M1J 1A8` is not — no lookup required. A rural LDU serves a delivery route
95
+ * rather than a block face, and it measures like one. On the same panel, the 114 rural rows:
96
+ *
97
+ * | 114 RURAL rows | p50 | p75 | p90 | ≤1 km |
98
+ * | -------------- | --------: | ----------: | ----------: | --------: |
99
+ * | locality-first | **929 m** | **2.02 km** | **5.73 km** | **53.5%** |
100
+ * | postcode-first | 2.08 km | 4.79 km | 8.11 km | 25.4% |
101
+ *
102
+ * Postcode-first is closer on only 26.3% of them, so the pattern below admits `[1-9]` in that position and nothing
103
+ * else. `509 Main Street South-West Falher AB T0H 1M0` is the worked case: 0.29 km from the locality centroid and 43.18
104
+ * km from its own postal code.
105
+ *
106
+ * The pooled CA number hides that entirely — 0.10 km p50 across both populations reads as a uniform win and is not one.
107
+ * A tier claim that averages two granularities is the thing this table exists to prevent.
108
+ *
109
+ * Lives in codex (per-address-system postal reference) so the Node result assembly (`mailwoman/geocode-core`) and the
110
+ * demo's pin ranking consume ONE tier definition — the 2026-08-11 staged-repoint e2e measured the two disagreeing.
111
+ */
112
+ export const UNIT_GRADE_POSTCODE: ReadonlyArray<RegExp> = [
113
+ // NL PC6 — `1012 LG` / `1012LG`.
114
+ /^\d{4}\s?[A-Z]{2}$/i,
115
+ // GB unit — outward (1-2 letters + digit + optional alnum) + inward `\d[A-Z]{2}`, the same shape
116
+ // `@mailwoman/codex/gb`'s UK_POSTCODE_PATTERN anchors, restated here so this module stays
117
+ // dependency-free within the package (the slices import THIS, never the reverse).
118
+ /^[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}$/i,
119
+ // CA urban LDU — `M1J 1A8`. The `[1-9]` in the second position is the whole tier claim: a `0` there marks a RURAL
120
+ // forward sortation area, which measures 2.08 km p50 against the locality's 929 m and does not belong here.
121
+ /^[A-Z][1-9][A-Z]\s?\d[A-Z]\d$/i,
122
+ ]
123
+
124
+ /**
125
+ * Strip everything but letters and digits, upper-cased — the comparison surface for "did the resolver hit the FULL code
126
+ * or a coarser stem?". `N7 0BT` and `N70BT` are the same code; `N7` is not.
127
+ */
128
+ const alnum = (s: string): string => s.replaceAll(/[^\p{L}\p{N}]/gu, "").toUpperCase()
129
+
130
+ /**
131
+ * True when a resolved postcode is an EXACT hit on a unit-grade code — the #977 three-way guard, shared by the Node
132
+ * ladder and the demo pin ranking:
133
+ *
134
+ * 1. The PARSED span is a full unit shape ({@link UNIT_GRADE_POSTCODE}), not a stem the user typed;
135
+ * 2. The node resolved (a coordinate is present — checked by the caller); and
136
+ * 3. The resolver's own hit is the FULL code, not a coarsened prefix (a 4-digit NL stem or a GB outward district is
137
+ * AREA-class, and promoting it is the exact trade the epoch convention forbids).
138
+ */
139
+ export function isUnitGradePostcodeHit(parsed: string, resolverName: string | undefined): boolean {
140
+ const value = parsed.trim()
141
+
142
+ if (!value || !UNIT_GRADE_POSTCODE.some((re) => re.test(value))) return false
143
+
144
+ return alnum(resolverName ?? "") === alnum(value)
145
+ }
146
+
147
+ /**
148
+ * Address systems whose AREA-grade postal code is still FINER than the locality containing it — the third granularity
149
+ * tier, between {@link UNIT_GRADE_POSTCODE} and the locality-first default.
150
+ *
151
+ * Whether a postal zone is coarser than its locality is a fact about a country's ADMINISTRATIVE geography, not about
152
+ * its postal system, and code length does not predict it: FR and DE are both 5-digit and land on opposite sides. France
153
+ * has ~35,000 communes and one code postal often spans several, so the commune is finer. A German Gemeinde can be
154
+ * enormous — Berlin is one WOF locality — so the PLZ is finer by a wide margin.
155
+ *
156
+ * Membership is earned by a full-panel measurement, the same bar {@link UNIT_GRADE_POSTCODE} sets for CA. Coordinate
157
+ * p50 on the OpenAddresses panels, locality-first (the default) against the postcode point:
158
+ *
159
+ * | country | rows | locality-first | postcode point | verdict |
160
+ * | ------- | ----: | -------------: | -------------: | ------------------------------------------------------------------ |
161
+ * | **DE** | 2,997 | 5.84 km | **1.24 km** | postcode, on EVERY percentile incl. p99 (21.50 → 10.57) |
162
+ * | FR | 3,000 | **0.97 km** | 2.64 km | locality, closer on 77.5% of rows |
163
+ * | IT | 2,833 | **1.34 km** | 3.05 km | locality, closer on 66.4% |
164
+ * | ES | 2,929 | **0.68 km** | 0.97 km | locality, but near a coin flip — 46.1% of rows prefer the postcode |
165
+ * | US | 577 | **2.28 km** | 4.15 km | locality (see below) |
166
+ *
167
+ * **The US row is measured on the population production actually sends to the ladder.** Its rooftop cascade is US-only
168
+ * by construction (`selectAddressPointsDB` composes `address-points-us-<slug>.db`), and it serves 94.2% of US queries,
169
+ * so only 577 of 10,000 panel rows reach an admin decision at all. Those are the rows no rooftop or interpolation shard
170
+ * could place, which skews rural — exactly where a locality centroid sits close and a ZIP zone is wide. Measured over
171
+ * all 10,000 rows instead, the US looks like a postcode-first country (2.41 km vs 3.63); that is a selection effect,
172
+ * and it is why this table reports 577 rows for the US and full panels for the others, which have no such cascade.
173
+ */
174
+ export const AREA_POSTCODE_FINER_THAN_LOCALITY: ReadonlySet<string> = new Set(["DE"])
175
+
176
+ /**
177
+ * True when this country's area-grade postal code outranks its locality. Absent or unknown country → false, so the
178
+ * locality-first convention is what an unscoped query gets.
179
+ */
180
+ export function areaPostcodeLeadsLocality(country: string | undefined): boolean {
181
+ return country !== undefined && AREA_POSTCODE_FINER_THAN_LOCALITY.has(country.trim().toUpperCase())
182
+ }
@@ -64,7 +64,7 @@ const SLOTS = ["country_surface", "country_ambiguous"]
64
64
  * Committed output path (a codex-derived artifact, like export-country-surfaces.ts — no argv, so the no-process-globals
65
65
  * lint policy holds; codex stays zero-runtime-dep).
66
66
  */
67
- const OUTPUT = resolve(import.meta.dirname, "../../data/gazetteer/country-surface-lexicon-v1.json")
67
+ const OUTPUT = resolve(import.meta.dirname, "../../../data/gazetteer/country-surface-lexicon-v1.json")
68
68
 
69
69
  /**
70
70
  * THE shared word-normalization rule (identical to build-gazetteer-anchor-lexicon.mjs and mirrored in
@@ -31,7 +31,7 @@ for (const [iso2, name] of ISO2_TO_NAME) {
31
31
  }
32
32
  }
33
33
 
34
- const out = resolve(import.meta.dirname, "../../corpus-python/src/mailwoman_train/data/country-surfaces.json")
34
+ const out = resolve(import.meta.dirname, "../../../corpus-python/src/mailwoman_train/data/country-surfaces.json")
35
35
 
36
36
  writeFileSync(
37
37
  out,
@@ -0,0 +1,143 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Regenerate `codex/country/population.ts` — the per-country population table — from GeoNames
7
+ * `countryInfo.txt` (https://download.geonames.org/export/dump/countryInfo.txt, CC-BY-4.0). The
8
+ * output is committed; this tool makes it reproducible (provenance), not a hand-typed dictionary.
9
+ *
10
+ * Why it exists (#1650): WOF carries no readable population for 147 of 237 country records
11
+ * (measured against the 2026-08-18 candidate build), so those countries entered every prominence
12
+ * race at an asserted ZERO — ranked below any namesake hamlet. The magnitude is what a fame race
13
+ * reads, so a census-vintage figure is entirely sufficient; currency of the estimate is not the
14
+ * point.
15
+ *
16
+ * Rows whose GeoNames population is 0 are DROPPED rather than emitted: an entry in this table is a
17
+ * positive claim, and the consumer's absence branch (`?? undefined`) must stay reachable for
18
+ * territories GeoNames itself declines to estimate (the meaning-of-zero rule).
19
+ *
20
+ * Usage: mailwoman dev generate country-population
21
+ */
22
+
23
+ import { writeFileSync } from "node:fs"
24
+ import { fileURLToPath } from "node:url"
25
+
26
+ import { APIClient, pluckResponseData } from "@mailwoman/core/api"
27
+
28
+ const SOURCE = "https://download.geonames.org/export/dump/countryInfo.txt"
29
+
30
+ /**
31
+ * The committed output path, resolved relative to this module (codex/tools/ → codex/country/). See
32
+ * `generate-country-reference.ts` for why this is `import.meta.url` rather than a `core` path helper (codex is
33
+ * zero-runtime-dep and `core` already references `codex` — importing core here would cycle the project graph).
34
+ */
35
+ const DEFAULT_OUT = fileURLToPath(new URL("../country/population.ts", import.meta.url))
36
+
37
+ /**
38
+ * Tab positions this tool reads from `countryInfo.txt`'s 19-column rows. Named so the parse states which columns it
39
+ * believes in; a GeoNames format change fails the count guard below rather than silently reading the wrong column.
40
+ */
41
+ const COLUMN_ISO2 = 0
42
+ const COLUMN_POPULATION = 7
43
+ const MINIMUM_COLUMNS = 8
44
+
45
+ /**
46
+ * GeoNames publishes ~250 countries/territories; a parse recovering fewer than this read the wrong column or a
47
+ * truncated body, and the guard fails loudly instead of committing a hollow table.
48
+ */
49
+ const MINIMUM_PLAUSIBLE_COUNTRIES = 200
50
+
51
+ /**
52
+ * Numbers below five digits are emitted bare — the house numeric-separator style groups by three and only from five
53
+ * digits up (`8450`, not `8_450`).
54
+ */
55
+ const SEPARATOR_MINIMUM = 10_000
56
+
57
+ /**
58
+ * Options for {@linkcode generateCountryPopulation}.
59
+ */
60
+ export interface GenerateCountryPopulationOptions {
61
+ /**
62
+ * Output path override. Default: `codex/country/population.ts` (the committed table).
63
+ */
64
+ out?: string
65
+ }
66
+
67
+ /**
68
+ * Summary returned by {@linkcode generateCountryPopulation}.
69
+ */
70
+ export interface GenerateCountryPopulationSummary {
71
+ countries: number
72
+ outPath: string
73
+ }
74
+
75
+ /**
76
+ * Fetch GeoNames `countryInfo.txt` and regenerate the committed `COUNTRY_POPULATION` table.
77
+ */
78
+ export async function generateCountryPopulation(
79
+ options: GenerateCountryPopulationOptions = {},
80
+ report?: (line: string) => void
81
+ ): Promise<GenerateCountryPopulationSummary> {
82
+ const outPath = options.out ?? DEFAULT_OUT
83
+
84
+ // `responseType: "text"` because the source is a tab-separated dump, not JSON.
85
+ const text = await new APIClient({ displayName: "geonames-country-info", retry: true })
86
+ .fetch<string>({ url: SOURCE, responseType: "text" })
87
+ .then(pluckResponseData)
88
+
89
+ const rows: Record<string, number> = {}
90
+
91
+ // oxlint-disable-next-line mailwoman/prefer-spliterator -- countryInfo.txt is ~35 kB and bounded (~300 rows)
92
+ for (const line of text.split("\n")) {
93
+ if (!line || line.startsWith("#")) continue
94
+ // oxlint-disable-next-line mailwoman/prefer-spliterator -- one 19-column row
95
+ const columns = line.split("\t")
96
+
97
+ if (columns.length < MINIMUM_COLUMNS) continue
98
+ const alpha2 = columns[COLUMN_ISO2]!.trim()
99
+ const population = Number(columns[COLUMN_POPULATION])
100
+
101
+ if (!/^[A-Z]{2}$/.test(alpha2)) continue
102
+
103
+ if (!Number.isFinite(population) || population <= 0) continue
104
+
105
+ rows[alpha2] = population
106
+ }
107
+
108
+ if (Object.keys(rows).length < MINIMUM_PLAUSIBLE_COUNTRIES) {
109
+ throw new Error(`generateCountryPopulation: only ${Object.keys(rows).length} rows parsed — format drift?`)
110
+ }
111
+
112
+ const body = Object.keys(rows)
113
+ .toSorted()
114
+ .map((k) => {
115
+ const n = rows[k]!
116
+ const literal = n >= SEPARATOR_MINIMUM ? n.toLocaleString("en-US").replaceAll(",", "_") : String(n)
117
+
118
+ return `\t${k}: ${literal},`
119
+ })
120
+ .join("\n")
121
+
122
+ const header = `/**
123
+ * @copyright Sister Software
124
+ * @license AGPL-3.0
125
+ * @author Teffen Ellis, et al.
126
+ *
127
+ * GENERATED — do not edit by hand. Per-country population, derived from GeoNames countryInfo.txt
128
+ * (https://download.geonames.org/export/dump/countryInfo.txt, CC-BY-4.0). Estimates are
129
+ * census-vintage; a prominence race reads the magnitude, not the currency. Countries GeoNames
130
+ * declines to estimate are ABSENT, never zero.
131
+ * Regenerate with: mailwoman dev generate country-population
132
+ */
133
+
134
+ /**
135
+ * ISO 3166-1 alpha-2 → population estimate.
136
+ */
137
+ export const COUNTRY_POPULATION: Readonly<Record<string, number>> = {`
138
+
139
+ writeFileSync(outPath, `${header}\n${body}\n}\n`)
140
+ report?.(`wrote ${outPath} (${Object.keys(rows).length} countries)`)
141
+
142
+ return { countries: Object.keys(rows).length, outPath }
143
+ }
@@ -17,6 +17,8 @@
17
17
  import { writeFileSync } from "node:fs"
18
18
  import { fileURLToPath } from "node:url"
19
19
 
20
+ import { APIClient, pluckResponseData } from "@mailwoman/core/api"
21
+
20
22
  const SOURCE = "https://raw.githubusercontent.com/mledoze/countries/master/countries.json"
21
23
 
22
24
  /**
@@ -97,10 +99,10 @@ export async function generateCountryReference(
97
99
  report?: (line: string) => void
98
100
  ): Promise<GenerateCountryReferenceSummary> {
99
101
  const outPath = options.out ?? DEFAULT_OUT
100
- const response = await fetch(SOURCE)
101
102
 
102
- if (!response.ok) throw new Error(`fetch ${SOURCE} failed: ${response.status}`)
103
- const countries = (await response.json()) as MledozeCountry[]
103
+ const countries = await new APIClient({ displayName: "mledoze-countries", retry: true })
104
+ .fetch<MledozeCountry[]>({ url: SOURCE })
105
+ .then(pluckResponseData)
104
106
 
105
107
  const rows: Record<string, CountryReferenceEntry> = {}
106
108