@mailwoman/corpus 7.2.0 → 7.2.1

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,415 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `locale` shard recipe — the multi-locale generalization of the `german` recipe. Reads REAL
7
+ * OpenAddresses tuples for a `--country` (DE/FR/NL/IT/ES), renders each via
8
+ * {@link synthesizeLocaleRow} in BOTH orders (`--intl-fraction`, default 0.4 international / the
9
+ * rest country-native), aligns to BIO, and emits a labeled JSONL. Generate-mode: it STREAMS each
10
+ * source CSV (`unzip -p` for cached zips, plain `createReadStream` for extracted CSVs) and
11
+ * reservoir-samples to {@link RESERVOIR_CAP} (so FR/ES countrywide work in bounded memory), then
12
+ * draws `--count` rows from the pool with the passed `random`. Ported from
13
+ * scripts/build-locale-shard.mjs.
14
+ *
15
+ * The reservoir uses its OWN seeded PRNG ({@link makeMulberry32}, per part), independent of the
16
+ * emit `random`, so the input sample is reproducible WITHOUT perturbing the synth/order draws.
17
+ *
18
+ * SURFACE DIVERSITY (#241): two per-country shape draws ride the emit loop, sized by the
19
+ * 2026-07-02 format-diversity audit against the `openaddresses-{es,nl,it}-sample.jsonl` observed
20
+ * forms. ES: the OpenCage template comma-joins the house number (`CALLE MAYOR, 12`) but all 3,000
21
+ * eval rows space-join (`CALLE MAYOR 12`) — {@link ES_SPACE_JOIN_FRACTION} of native rows collapse
22
+ * the comma. NL: OA (and the eval, 3,000/3,000) glue the postcode (`1187LM`) while the national
23
+ * convention spaces it (`1187 LM`) — {@link NL_GLUED_POSTCODE_FRACTION} of rows keep the glued
24
+ * source shape, the rest the spaced conventional one. These draws are consumed ONLY for their
25
+ * country, so DE/FR emit streams are unchanged for a given seed.
26
+ */
27
+
28
+ import { spawn } from "node:child_process"
29
+ import { createReadStream } from "node:fs"
30
+
31
+ import { dataRootPath } from "@mailwoman/core/utils"
32
+ import { CSVSpliterator } from "spliterator"
33
+
34
+ import { stableSourceID } from "../adapter.ts"
35
+ import { alignRow } from "../align.ts"
36
+ import { type LocaleBaseTuple, synthesizeLocaleRow } from "../synthesize-german.ts"
37
+ import { makeMulberry32, type ShardRecipe } from "./scaffold.ts"
38
+
39
+ /**
40
+ * One per-country OA source part: either a cached `zip` + `csv` member (streamed via `unzip -p`) or an extracted plain
41
+ * `path` (streamed via `createReadStream`). Both carry the standard OA header
42
+ * (LON,LAT,NUMBER,STREET,UNIT,CITY,DISTRICT,REGION,POSTCODE,ID,HASH). An optional `region` fallback covers countries
43
+ * whose REGION column is empty (DE — the Bundesland is implied by the per-state file).
44
+ */
45
+ export interface LocalePart {
46
+ zip?: string
47
+ csv?: string
48
+ path?: string
49
+ region?: string
50
+ /**
51
+ * NZ — the OA DISTRICT column holds the city (`Auckland`) and CITY holds the suburb (`Birkenhead`). When set, map
52
+ * DISTRICT→locality and CITY→dependent_locality (falling back to CITY→locality when DISTRICT is empty, ~18% of NZ
53
+ * rows). Without this, the default CITY→locality mapping wrongly trains the suburb as the locality.
54
+ */
55
+ districtAsLocality?: boolean
56
+ }
57
+
58
+ interface LocaleCountrySource {
59
+ source: string
60
+ parts: LocalePart[]
61
+ /**
62
+ * The `corpus_version` stamped on emitted rows. DE/FR keep the historical `0.4.0` (regenerating those shards must
63
+ * stay lineage-identical); ES/IT/NL are the #241 staging lineage (`v0.9.9-es-it-nl`).
64
+ */
65
+ corpusVersion: string
66
+ }
67
+
68
+ /**
69
+ * Per-country OA sources + the source name used in the corpus. DE/FR still point at the legacy `/tmp/oa-cache` zips
70
+ * (their historical build inputs — materialize them there to regenerate). ES/NL read the extracted countrywide CSVs and
71
+ * IT the cached national zip under `$MAILWOMAN_DATA_ROOT` (#241; the fresh ES extract is OA-conformed, so the old
72
+ * raw-CNIG conform map is gone). DE carries a per-part `region` fallback (its REGION column is empty; the
73
+ * international-order tail needs it, #327). FR/NL/IT/ES REGION is populated per-row (ES = comunidad autónoma, IT =
74
+ * regione, NL = province).
75
+ */
76
+ const COUNTRY_SOURCES: Record<string, LocaleCountrySource> = {
77
+ DE: {
78
+ source: "synth-german",
79
+ corpusVersion: "0.4.0",
80
+ parts: [
81
+ { zip: "/tmp/oa-cache/de__berlin.zip", csv: "de/berlin.csv", region: "Berlin" },
82
+ { zip: "/tmp/oa-cache/de__sn__statewide.zip", csv: "de/sn/statewide.csv", region: "Sachsen" },
83
+ ],
84
+ },
85
+ FR: {
86
+ source: "synth-fr",
87
+ corpusVersion: "0.4.0",
88
+ parts: [{ zip: "/tmp/oa-cache/fr__countrywide.zip", csv: "fr/countrywide.csv" }],
89
+ },
90
+ NL: {
91
+ source: "synth-nl",
92
+ corpusVersion: "0.9.9",
93
+ parts: [{ path: dataRootPath("openaddresses", "extracted", "nl", "countrywide.csv") }],
94
+ },
95
+ IT: {
96
+ source: "synth-it",
97
+ corpusVersion: "0.9.9",
98
+ parts: [{ zip: dataRootPath("oa-cache", "it__countrywide.zip"), csv: "it/countrywide.csv" }],
99
+ },
100
+ ES: {
101
+ source: "synth-es",
102
+ corpusVersion: "0.9.9",
103
+ parts: [{ path: dataRootPath("openaddresses", "extracted", "es", "countrywide.csv") }],
104
+ },
105
+ NZ: {
106
+ // LINZ-derived OA countrywide extract (2.12M rows). `districtAsLocality` inverts the CITY/DISTRICT
107
+ // mapping: DISTRICT holds the city (Auckland), CITY the suburb (Birkenhead). NZ OA carries no postcode.
108
+ source: "synth-nz",
109
+ corpusVersion: "0.9.9",
110
+ // Eval holdout: a probe build excludes ~12% of NZ localities (a locality-bucket split) for a source-disjoint
111
+ // coord board; that split is a BUILD-TIME concern (scratchpad), so the committed recipe reads the full CSV.
112
+ parts: [{ path: dataRootPath("openaddresses", "extracted", "nz", "countrywide.csv"), districtAsLocality: true }],
113
+ },
114
+ }
115
+
116
+ /**
117
+ * Per-part reservoir cap. Streaming + Algorithm-R reservoir sampling to this size keeps memory bounded regardless of
118
+ * source size, where buffering the whole CSV OOMs / overflows on FR/ES countrywide (~2.5 GB, ~25M rows). DE/NL-scale
119
+ * sources (≤ ~1.2M) fit entirely, so they're sampled losslessly.
120
+ */
121
+ const RESERVOIR_CAP = 1_200_000
122
+
123
+ /**
124
+ * Fraction of NATIVE-order ES rows whose street→house join is space-collapsed (`CALLE MAYOR 12`) instead of the
125
+ * template's comma (`CALLE MAYOR, 12`). Both are real Spanish surfaces — the comma is the official convention, the
126
+ * space is what OA-derived feeds (and all 3,000 `openaddresses-es-sample.jsonl` rows) carry. 0.5 teaches both.
127
+ */
128
+ const ES_SPACE_JOIN_FRACTION = 0.5
129
+
130
+ /**
131
+ * Fraction of NL rows whose postcode keeps OA's glued shape (`1187LM`) instead of the spaced national convention (`1187
132
+ * LM`). The eval sample is 100% glued; the conventional spaced form is the `1012 LM` two-letter-suffix shape the model
133
+ * currently glues onto the city (#241). 0.5 teaches both.
134
+ */
135
+ const NL_GLUED_POSTCODE_FRACTION = 0.5
136
+
137
+ /**
138
+ * OA CITY-noise normalization (#241) — the documented cleaning step, derived from the 2026-07-02 FULL-STREAM audit of
139
+ * the ES (15.6M rows), IT (13.9M), and NL (9.1M) sources (not a hand-list). Returns the cleaned city, or `null` to drop
140
+ * the tuple.
141
+ *
142
+ * Cleaned classes:
143
+ *
144
+ * 1. DROP pseudo-localities — the ES cadastral aggregates (`Comunidad de 09076, 09150 y 09578`, `Ledanía de …`; 0.06% of
145
+ * ES rows): any CITY containing a comma or a ≥4-digit run is a land-register aggregate, not a renderable city.
146
+ * Structural, locale-safe — NL's genuine `2e Valthermond` (one digit) survives; IT/NL have zero hits.
147
+ * 2. STRIP a trailing parenthesized 1–3-letter admin code — the NL BAG province disambiguator (`Bergen (NH)`, `Rijswijk
148
+ * (GLD)` → `Bergen`, `Rijswijk`; 0.13% of NL rows). The analogue of the German Kreis/region-suffix class (#241 names
149
+ * `Rabenau Sachs` / `Weißwasser /O.L.`): an admin-region gloss glued onto the locality value that dirties locality
150
+ * labels.
151
+ *
152
+ * Audit-verified NON-noise, deliberately NOT cleaned (a naive suffix rule would mangle real names):
153
+ *
154
+ * - ES/IT city-ends-with-province (`Alhama de Almería`, `GENZANO DI ROMA`; ~0.8% each): genuine toponyms whose linking
155
+ * `de`/`di` makes them full names, unlike the German glued-abbreviation class.
156
+ * - ES bilingual slash names (`Laudio/Llodio`; 2.16%): official co-names — the eval expects them verbatim.
157
+ * - IT ALL-CAPS city casing (98.79% of the source, and the eval's observed form): casing is the #829 case-augmentation
158
+ * lever, not this shard's.
159
+ */
160
+ export function cleanCityNoise(city: string): string | null {
161
+ if (/,|\d{4}/.test(city)) return null
162
+
163
+ const stripped = city.replace(/\s*\(\p{L}{1,3}\)\s*$/u, "").trim()
164
+
165
+ return stripped || null
166
+ }
167
+
168
+ interface ColumnIndex {
169
+ num: number
170
+ street: number
171
+ city: number
172
+ district: number
173
+ region: number
174
+ post: number
175
+ }
176
+
177
+ /**
178
+ * Stream real tuples out of an OA source part and reservoir-sample to {@link RESERVOIR_CAP}. Reads the CSV row-by-row —
179
+ * `unzip -p | CSVSpliterator` for zip parts, `createReadStream | CSVSpliterator` for extracted parts (both bounded
180
+ * memory) — and keeps a uniform random sample (Algorithm R) seeded by `rng`, separate from the emit loop's PRNG. NO
181
+ * global dedup (a 25M-key Set would OOM; OA rows are near-unique). The city passes through {@link cleanCityNoise}; the
182
+ * region falls back to `part.region` when the row's REGION cell is empty (DE).
183
+ *
184
+ * Exported for {@link locale.test.ts} — the CSV read path (quote handling, CRLF, region fallback) has no other test.
185
+ */
186
+ export async function readTuples(part: LocalePart, rng: () => number): Promise<LocaleBaseTuple[]> {
187
+ let input: NodeJS.ReadableStream
188
+
189
+ if (part.path) {
190
+ // No `encoding` — CSVSpliterator delimits raw bytes and decodes utf-8 itself; a string stream
191
+ // (from `{ encoding: "utf8" }`) would defeat its byte-range scanner.
192
+ input = createReadStream(part.path)
193
+ } else {
194
+ const child = spawn("unzip", ["-p", part.zip!, part.csv!])
195
+ child.on("error", (err) => {
196
+ console.error(` WARN: unzip failed for ${part.zip}: ${err.message}`)
197
+ })
198
+ input = child.stdout!
199
+ }
200
+ const get = (cells: string[], i: number): string => (i >= 0 && i < cells.length ? (cells[i] ?? "").trim() : "")
201
+ const reservoir: LocaleBaseTuple[] = []
202
+ let cols: ColumnIndex | null = null
203
+ let header: string[] | null = null
204
+ let seen = 0
205
+ let dropped = 0
206
+
207
+ try {
208
+ // CSVSpliterator handles OA's quoted fields (embedded commas/newlines) and CRLF row terminators
209
+ // (spliterator ≥ 3.2.0); `header: false` yields the header row too, so we build the column index
210
+ // from it exactly as the prior hand-rolled split did. Verified byte-identical tuples vs that split
211
+ // on 10M+ real OA rows — the only raw-cell difference was a trailing CR on the discarded HASH column.
212
+ for await (const cells of CSVSpliterator.fromAsync<string[]>(input, {
213
+ mode: "array",
214
+ header: false,
215
+ enableQuoteHandling: true,
216
+ })) {
217
+ if (header === null) {
218
+ header = cells.map((h) => h.trim().toLowerCase())
219
+ const ix = (name: string): number => header!.indexOf(name)
220
+ cols = {
221
+ num: ix("number"),
222
+ street: ix("street"),
223
+ city: ix("city"),
224
+ district: ix("district"),
225
+ region: ix("region"),
226
+ post: ix("postcode"),
227
+ }
228
+
229
+ continue
230
+ }
231
+
232
+ if (cols === null) continue
233
+ const street = get(cells, cols.street)
234
+ const rawCity = get(cells, cols.city)
235
+
236
+ if (!street || !rawCity) continue
237
+
238
+ // Default: CITY → locality. NZ (`districtAsLocality`) inverts it — the OA DISTRICT holds the city
239
+ // (`Auckland`) and CITY holds the suburb (`Birkenhead`), so DISTRICT → locality and CITY →
240
+ // dependent_locality. When DISTRICT is empty (~18% of NZ rows), fall back to CITY → locality with no
241
+ // sub-locality. See {@link LocalePart.districtAsLocality}.
242
+ let locality: string | null
243
+ let dependent_locality: string | undefined
244
+
245
+ if (part.districtAsLocality) {
246
+ const cleanedDistrict = cleanCityNoise(get(cells, cols.district))
247
+
248
+ if (cleanedDistrict) {
249
+ locality = cleanedDistrict
250
+ dependent_locality = cleanCityNoise(rawCity) ?? undefined
251
+ } else {
252
+ locality = cleanCityNoise(rawCity)
253
+ }
254
+ } else {
255
+ locality = cleanCityNoise(rawCity)
256
+ }
257
+
258
+ if (!locality) {
259
+ dropped++
260
+
261
+ continue
262
+ }
263
+ const tuple: LocaleBaseTuple = {
264
+ house_number: get(cells, cols.num),
265
+ street,
266
+ locality,
267
+ region: get(cells, cols.region) || part.region || "",
268
+ postcode: get(cells, cols.post),
269
+ ...(dependent_locality ? { dependent_locality } : {}),
270
+ }
271
+ seen++
272
+
273
+ if (reservoir.length < RESERVOIR_CAP) {
274
+ reservoir.push(tuple)
275
+ } else {
276
+ const j = Math.floor(rng() * seen)
277
+
278
+ // 0 .. seen-1
279
+ if (j < RESERVOIR_CAP) {
280
+ reservoir[j] = tuple
281
+ }
282
+ }
283
+ }
284
+ } catch (err) {
285
+ console.error(` WARN: read failed for ${part.path ?? part.zip}: ${(err as Error).message}`)
286
+
287
+ return []
288
+ }
289
+
290
+ console.error(` ${part.path ?? part.csv}: ${reservoir.length} sampled of ${seen} rows (${dropped} city-noise drops)`)
291
+
292
+ return reservoir
293
+ }
294
+
295
+ export const localeRecipe: ShardRecipe = {
296
+ name: "locale",
297
+ description: "Per-locale coverage rows (DE/FR/NL/IT/ES) from real OA tuples, both orders → synthesizeLocaleRow",
298
+ mode: "generate",
299
+ options: [
300
+ { flag: "--country <cc>", description: "Target country (DE|FR|NL|IT|ES|NZ). Default DE" },
301
+ { flag: "--intl-fraction <f>", description: "Fraction rendered international order. Default 0.4" },
302
+ ],
303
+ async run(opts, write) {
304
+ // Emit PRNG: the legacy build-locale-shard.mjs seeded mulberry32(opts.seed). The reservoir uses a
305
+ // SEPARATE per-part mulberry32 (below) so input sampling never perturbs this emit stream.
306
+ const random = makeMulberry32(opts.seed)
307
+ const country = (opts.country ?? "DE").toUpperCase()
308
+ const countrySource = COUNTRY_SOURCES[country]
309
+
310
+ if (!countrySource) {
311
+ throw new Error(
312
+ `No OA sources registered for --country ${country}. Known: ${Object.keys(COUNTRY_SOURCES).join(", ")}.`
313
+ )
314
+ }
315
+ const intlFraction = opts.intlFraction ?? 0.4
316
+
317
+ if (!(intlFraction >= 0 && intlFraction <= 1)) {
318
+ throw new Error(`--intl-fraction must be in [0, 1], got ${intlFraction}`)
319
+ }
320
+ const source = opts.sourceName ?? countrySource.source
321
+ const count = opts.count ?? 4000
322
+ const { parts } = countrySource
323
+
324
+ const pool: LocaleBaseTuple[] = []
325
+
326
+ for (let pi = 0; pi < parts.length; pi++) {
327
+ // A reservoir PRNG per part, seeded but independent of the emit loop's `random`, so the sample is
328
+ // reproducible without perturbing the synth/order draws.
329
+ const reservoirRng = makeMulberry32((opts.seed ^ (0x9e3779b9 * (pi + 1))) >>> 0)
330
+ const t = await readTuples(parts[pi]!, reservoirRng)
331
+
332
+ for (const x of t) {
333
+ pool.push(x)
334
+ } // NOT pool.push(...t) — spreading huge arrays overflows the stack
335
+ }
336
+
337
+ if (pool.length === 0) {
338
+ throw new Error(`No ${country} tuples found — are the source CSVs/zips present? (see COUNTRY_SOURCES)`)
339
+ }
340
+
341
+ let emitted = 0
342
+ let skipped = 0
343
+ let guard = 0
344
+ const N = pool.length
345
+
346
+ while (emitted < count && guard++ < count * 6) {
347
+ const base = pool[Math.floor(random() * N)]!
348
+ const order = random() < intlFraction ? "international" : "native"
349
+ // Per-country surface-shape draws (#241) — consumed ONLY for that country, so the DE/FR emit
350
+ // streams for a given seed are unchanged by their existence.
351
+ const nativeHouseJoin =
352
+ country === "ES" ? (random() < ES_SPACE_JOIN_FRACTION ? ("space" as const) : ("template" as const)) : undefined
353
+ const postcodeShape =
354
+ country === "NL"
355
+ ? random() < NL_GLUED_POSTCODE_FRACTION
356
+ ? ("as-source" as const)
357
+ : ("conventional" as const)
358
+ : undefined
359
+ const synth = synthesizeLocaleRow(base, country, { random, order, nativeHouseJoin, postcodeShape })
360
+
361
+ if (!synth) {
362
+ skipped++
363
+ continue
364
+ }
365
+
366
+ if (opts.golden) {
367
+ // Golden rows must round-trip through alignRow exactly like training rows (#241 done-when): a
368
+ // render that can't be BIO-labeled can't serve as a parser golden either. Consumes no RNG draw.
369
+ const goldenCanonical = {
370
+ raw: synth.raw,
371
+ components: synth.components,
372
+ country,
373
+ locale: synth.locale,
374
+ source,
375
+ source_id: "golden:align-check",
376
+ }
377
+ const goldenAligned = alignRow(goldenCanonical as Parameters<typeof alignRow>[0])
378
+
379
+ if (goldenAligned.kind !== "labeled" || !goldenAligned.row) {
380
+ skipped++
381
+ continue
382
+ }
383
+ write(JSON.stringify({ raw: synth.raw, components: synth.components, country, order }) + "\n")
384
+ emitted++
385
+ continue
386
+ }
387
+ const sourceID = stableSourceID(source, {
388
+ street: synth.components.street,
389
+ house_number: synth.components.house_number,
390
+ locality: synth.components.locality,
391
+ postcode: synth.components.postcode,
392
+ })
393
+ const canonical = {
394
+ raw: synth.raw,
395
+ components: synth.components,
396
+ country,
397
+ locale: synth.locale,
398
+ source,
399
+ source_id: sourceID,
400
+ corpus_version: countrySource.corpusVersion,
401
+ license: `OpenAddresses ${country} tuples, rendered ${order}-order — see ingest SOURCES`,
402
+ }
403
+ const aligned = alignRow(canonical as Parameters<typeof alignRow>[0])
404
+
405
+ if (aligned.kind !== "labeled" || !aligned.row) {
406
+ skipped++
407
+ continue
408
+ }
409
+ write(JSON.stringify({ ...aligned.row, synth_method: source, synth_order: order, synth_base_id: null }) + "\n")
410
+ emitted++
411
+ }
412
+
413
+ return { emitted, skipped }
414
+ },
415
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `nl-postcode` — the Dutch full-form postcode shard (#924). The model reads the DIGITS-FIRST NL
7
+ * postcode "1012 LG" as a house number + a 2-letter street ("1012 LG Amsterdam" → house_number
8
+ * 1012 / street "LG" / locality Amsterdam), and that spurious street context then pulls the
9
+ * locality into the US situs tier (Amsterdam → Amsterdam, NY). Letters-first postcodes (UK
10
+ * "SW1A 1AA") parse natively; the `\d{4} [A-Z]{2}` shape does not, and the soft query-shape prior
11
+ * (0.9 log-odds) can't overcome the strong house-number reading of a leading 4-digit token.
12
+ *
13
+ * This is the model-first fix as DATA (the #723/#901 discipline — teach the boundary, don't
14
+ * override the decoder): real NL (street, number, postcode, city) tuples in the orders Dutch
15
+ * addresses actually use, with the full postcode tagged as ONE postcode span. Both the SPACED
16
+ * ("1012 LG", the failing form — a 2-token span) and UNSPACED ("1012LG", 1 token) forms are
17
+ * emitted so the model learns the digits-first postcode regardless of spacing; the three orders
18
+ * keep polarity balanced (the v1.9.9 lesson).
19
+ */
20
+
21
+ import { alignAndWrite, makeMulberry32, readTuples, type ShardRecipe, shardSourceID } from "./scaffold.ts"
22
+
23
+ /** "1012LG" → "1012 LG". The tuples carry the unspaced OA form; the spaced form is the failing case. */
24
+ function spacePostcode(pc: string): string {
25
+ return pc.replace(/^(\d{4})([A-Z]{2})$/, "$1 $2")
26
+ }
27
+
28
+ export const nlPostcodeRecipe: ShardRecipe = {
29
+ name: "nl-postcode",
30
+ description:
31
+ "NL full-form postcode (#924): teach '\\d{4} [A-Z]{2}' = postcode, not house#+street — spaced + unspaced, 3 orders",
32
+ mode: "tuples",
33
+ async run(opts, write) {
34
+ makeMulberry32(opts.seed)
35
+ let read = 0
36
+ let emitted = 0
37
+ let skipped = 0
38
+
39
+ for await (const t of readTuples(opts.input!)) {
40
+ read++
41
+ const street = String(t.street ?? "").trim()
42
+ const city = String(t.locality ?? "").trim()
43
+ const number = String(t.number ?? "").trim()
44
+ const rawPostcode = String(t.postcode ?? "")
45
+ .trim()
46
+ .toUpperCase()
47
+ .replace(/\s+/g, "")
48
+
49
+ if (!street || !city || !number || !/^\d{4}[A-Z]{2}$/.test(rawPostcode)) {
50
+ skipped++
51
+ continue
52
+ }
53
+
54
+ // Spacing rotates so the model sees BOTH the failing spaced form and the unspaced form; the
55
+ // components.postcode value MUST match the raw form so alignment tags the right span.
56
+ const spaced = read % 2 === 0
57
+ const postcode = spaced ? spacePostcode(rawPostcode) : rawPostcode
58
+
59
+ // The three orders Dutch addresses use. `street number, postcode city` is canonical; the
60
+ // pc-first form is where the leading digits most strongly mis-read as a house number.
61
+ const order = read % 3
62
+ let raw: string
63
+
64
+ if (order === 0) {
65
+ raw = `${street} ${number}, ${postcode} ${city}`
66
+ } else if (order === 1) {
67
+ raw = `${postcode} ${city}, ${street} ${number}`
68
+ } else {
69
+ raw = `${city}, ${postcode}, ${street} ${number}`
70
+ }
71
+
72
+ const components: Record<string, string> = {
73
+ street,
74
+ house_number: number,
75
+ postcode,
76
+ locality: city,
77
+ }
78
+ const source_id = shardSourceID("synth-nl-postcode", {
79
+ ...components,
80
+ o: String(order),
81
+ s: spaced ? "1" : "0",
82
+ v: String(read),
83
+ })
84
+ const canonical = {
85
+ raw,
86
+ components,
87
+ country: "NL",
88
+ locale: "nl-NL",
89
+ source: "synth-nl-postcode",
90
+ source_id,
91
+ corpus_version: "0.10.0",
92
+ license:
93
+ "Synthetic — nl-postcode; (street, number, postcode, city) from OpenAddresses NL (per-source attribution in the model card)",
94
+ }
95
+
96
+ if (alignAndWrite(write, canonical, "nl-postcode")) {
97
+ emitted++
98
+ } else {
99
+ skipped++
100
+ }
101
+ }
102
+
103
+ return { read, emitted, skipped }
104
+ },
105
+ }