@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,471 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `country-balanced` shard recipe — the BALANCED, MODEL-FIRST country-coverage shard (#464). The
7
+ * shipped model is STARVED on `country` (P=R=F1=0 on the homograph eval), so this fills the void
8
+ * the way the `unit` shard did, but built to AVOID over-firing "trailing token ⇒ country". Three
9
+ * ingredients, ported faithfully from scripts/build-country-shard-balanced.mjs:
10
+ *
11
+ * 1. Breadth/recall — real OA skeletons (US/DE/FR/IT/NL) with a country token in a varied surface form
12
+ * from `@mailwoman/codex/country` (canonical / endonym / ISO code), + ~30% country-ABSENT
13
+ * negatives (teach O-emission, the precision floor).
14
+ * 2. Homograph CONTRAST pairs — each true country-name homograph (Georgia, Jordan, Lebanon, Mexico,
15
+ * Peru, Turkey) rendered BOTH ways: as `country` (foreign-city context) AND as the US
16
+ * `region`/`locality` (US-ZIP context). Teaches that the label is CONTEXTUAL, not
17
+ * positional.
18
+ * 3. Code-as-region negatives — 2-letter codes that are both a US state abbrev and an ISO country code
19
+ * (CA/GA/IN/MA/PA/AL) in US-ZIP context → must read as `region`, never `country`.
20
+ *
21
+ * `--golden` emits a held-out synthetic val over the VT (US) + Berlin (DE) holdouts. This is a
22
+ * `generate`-mode recipe that still reads REAL tuples off disk — `--count` bounds the OUTPUT, not
23
+ * the input. The passed `random` (the framework LCG) is consumed in the exact call order the
24
+ * legacy script used.
25
+ */
26
+
27
+ import { spawnSync } from "node:child_process"
28
+
29
+ import { COUNTRY_SURFACE_FORMS, CountryNames } from "@mailwoman/codex/country"
30
+ import type { ComponentTag } from "@mailwoman/core/types"
31
+
32
+ import { stableSourceID } from "../adapter.ts"
33
+ import { alignRow } from "../align.ts"
34
+ import type { CanonicalRow } from "../types.ts"
35
+ import { makeMulberry32, type ShardRecipe } from "./scaffold.ts"
36
+
37
+ // v2: the country TOKEN is decoupled from the skeleton's locale and drawn from a BROAD pool — every
38
+ // ISO canonical name + every curated surface form (endonyms/abbrevs). Surface forms are over-weighted
39
+ // so endonyms/abbrevs ("Deutschland","USA","NL") get strong signal.
40
+ const COUNTRY_FORM_POOL = (() => {
41
+ const surface = Object.values(COUNTRY_SURFACE_FORMS).flat() // endonyms + abbrevs + canonical (curated)
42
+ const names = [...CountryNames]
43
+
44
+ // all ~249 ISO canonical English names (breadth)
45
+ return { surface, names }
46
+ })()
47
+ const COUNTRY_ABSENT_PROB = 0.3 // negatives: rows with NO country token → teach golden precision
48
+
49
+ /** A cached OpenAddresses extract + the implied iso2/region/render-order. */
50
+ interface CountrySource {
51
+ zip: string
52
+ csv: string
53
+ iso2: string
54
+ region: string
55
+ order: string
56
+ }
57
+
58
+ // Multi-locale OA sources. region = implied admin where the extract is single-region (US states, DE
59
+ // Saxony); countrywide extracts (FR/IT/NL) read region from the CSV when present.
60
+ const SOURCES: readonly CountrySource[] = [
61
+ { zip: "/tmp/oa-cache/us__ia__statewide.zip", csv: "us/ia/statewide.csv", iso2: "US", region: "IA", order: "us" },
62
+ { zip: "/tmp/oa-cache/us__il__cook.zip", csv: "us/il/cook.csv", iso2: "US", region: "IL", order: "us" },
63
+ { zip: "/tmp/oa-cache/us__mt__statewide.zip", csv: "us/mt/statewide.csv", iso2: "US", region: "MT", order: "us" },
64
+ { zip: "/tmp/oa-cache/us__sd__statewide.zip", csv: "us/sd/statewide.csv", iso2: "US", region: "SD", order: "us" },
65
+ { zip: "/tmp/oa-cache/de__sn__statewide.zip", csv: "de/sn/statewide.csv", iso2: "DE", region: "", order: "eu" },
66
+ { zip: "/tmp/oa-cache/fr__countrywide.zip", csv: "fr/countrywide.csv", iso2: "FR", region: "", order: "fr" },
67
+ // ES uses the Spanish IGN schema, not the OA standard columns — skipped here (codex still recognizes
68
+ // "España"/"Spain"). A dedicated IGN adapter is a follow-up.
69
+ { zip: "/tmp/oa-cache/it__countrywide.zip", csv: "it/countrywide.csv", iso2: "IT", region: "", order: "eu" },
70
+ { zip: "/tmp/oa-cache/nl__countrywide.zip", csv: "nl/countrywide.csv", iso2: "NL", region: "", order: "eu" },
71
+ ]
72
+ // Held-out for --golden: Vermont (US holdout) + Berlin (DE holdout) — geographic split, never trained.
73
+ const EVAL_SOURCES: readonly CountrySource[] = [
74
+ { zip: "/tmp/oa-cache/us__vt__statewide.zip", csv: "us/vt/statewide.csv", iso2: "US", region: "VT", order: "us" },
75
+ { zip: "/tmp/oa-cache/de__berlin.zip", csv: "de/berlin.csv", iso2: "DE", region: "", order: "eu" },
76
+ ]
77
+
78
+ /** A real tuple read out of a cached OA zip (+ the source's iso2/render-order). */
79
+ interface CountryTuple {
80
+ house_number: string
81
+ street: string
82
+ locality: string
83
+ region: string
84
+ postcode: string
85
+ iso2: string
86
+ order: string
87
+ }
88
+
89
+ function splitCSV(line: string): string[] {
90
+ const out: string[] = []
91
+ let cur = "",
92
+ inQ = false
93
+
94
+ for (let i = 0; i < line.length; i++) {
95
+ const c = line[i]
96
+
97
+ if (inQ) {
98
+ if (c === '"') {
99
+ if (line[i + 1] === '"') {
100
+ cur += '"'
101
+ i++
102
+ } else {
103
+ inQ = false
104
+ }
105
+ } else {
106
+ cur += c
107
+ }
108
+ } else if (c === '"') {
109
+ inQ = true
110
+ } else if (c === ",") {
111
+ out.push(cur)
112
+ cur = ""
113
+ } else {
114
+ cur += c
115
+ }
116
+ }
117
+ out.push(cur)
118
+
119
+ return out
120
+ }
121
+
122
+ function readTuples(source: CountrySource, limit: number): CountryTuple[] {
123
+ // countrywide extracts (FR/IT/NL) are GB-scale — cap the bytes with `head` (read ~8 lines per wanted
124
+ // tuple to survive dedup/skips) so the toString stays under V8's string limit.
125
+ const maxLines = Math.max(limit * 8, 20000) + 1
126
+ const r = spawnSync("bash", ["-c", `unzip -p "${source.zip}" "${source.csv}" | head -n ${maxLines}`], {
127
+ maxBuffer: 1024 * 1024 * 1024,
128
+ encoding: "buffer",
129
+ })
130
+
131
+ if (r.status !== 0) {
132
+ console.error(` WARN: unzip failed for ${source.zip} (status ${r.status})`)
133
+
134
+ return []
135
+ }
136
+ const lines = r.stdout.toString("utf8").split(/\r?\n/)
137
+
138
+ if (lines.length < 2) return []
139
+ const header = splitCSV(lines[0]!).map((h) => h.trim().toLowerCase())
140
+ const idx = (n: string): number => header.indexOf(n)
141
+ const iNum = idx("number"),
142
+ iStreet = idx("street"),
143
+ iCity = idx("city"),
144
+ iRegion = idx("region"),
145
+ iPost = idx("postcode")
146
+ const get = (cells: string[], i: number): string => (i >= 0 && i < cells.length ? (cells[i] ?? "").trim() : "")
147
+ const tuples: CountryTuple[] = []
148
+ const seen = new Set<string>()
149
+
150
+ for (let li = 1; li < lines.length && tuples.length < limit; li++) {
151
+ if (!lines[li]) continue
152
+ const cells = splitCSV(lines[li]!)
153
+ const street = get(cells, iStreet),
154
+ locality = get(cells, iCity),
155
+ house_number = get(cells, iNum)
156
+
157
+ if (!street || !locality || !house_number) continue
158
+ const key = `${house_number}|${street}|${locality}`.toLowerCase()
159
+
160
+ if (seen.has(key)) continue
161
+ seen.add(key)
162
+ tuples.push({
163
+ house_number,
164
+ street,
165
+ locality,
166
+ region: get(cells, iRegion) || source.region,
167
+ postcode: get(cells, iPost),
168
+ iso2: source.iso2,
169
+ order: source.order,
170
+ })
171
+ }
172
+
173
+ return tuples
174
+ }
175
+
176
+ /** Pick a country token from the BROAD pool, or null (a country-absent negative). v2. */
177
+ function pickCountry(random: () => number): string | null {
178
+ if (random() < COUNTRY_ABSENT_PROB) return null // negative — teaches "trailing token != always country"
179
+ // 60% curated surface forms (endonym/abbrev variety), 40% broad ISO canonical names (coverage).
180
+ const pool = random() < 0.6 ? COUNTRY_FORM_POOL.surface : COUNTRY_FORM_POOL.names
181
+
182
+ return pool[Math.floor(random() * pool.length)]!
183
+ }
184
+
185
+ /** Render the address body in native-ish order. `country` null → a country-ABSENT negative row. */
186
+ function renderCountry(
187
+ random: () => number,
188
+ t: CountryTuple,
189
+ country: string | null
190
+ ): { fmt: string; raw: string; components: Partial<Record<ComponentTag, string>> } {
191
+ const { house_number: hn, street, locality: loc, region: reg, postcode: pc, order } = t
192
+ const components: Partial<Record<ComponentTag, string>> = { house_number: hn, street, locality: loc }
193
+
194
+ if (reg) {
195
+ components.region = reg
196
+ }
197
+
198
+ if (pc) {
199
+ components.postcode = pc
200
+ }
201
+ let body: string
202
+
203
+ if (order === "us") {
204
+ const regPc = [reg, pc].filter(Boolean).join(" ")
205
+ body = `${hn} ${street}, ${loc}${regPc ? ", " + regPc : ""}`
206
+ } else if (order === "fr") {
207
+ // French is NUMBER-street, postcode-city: "84 Route de la Fontaine, 75008 Paris".
208
+ body = `${hn} ${street}, ${[pc, loc].filter(Boolean).join(" ")}`
209
+ } else {
210
+ // DE/IT/NL: street-number, postcode-city: "Pariser Platz 1, 10117 Berlin".
211
+ const pcCity = [pc, loc].filter(Boolean).join(" ")
212
+ body = `${street} ${hn}, ${pcCity}`
213
+ }
214
+
215
+ if (!country) {
216
+ // Negative: a normal address, NO country token/component. Teaches that a trailing region/city/
217
+ // postcode is NOT a country (counters the v1 golden over-firing).
218
+ return { fmt: "negative", raw: body, components }
219
+ }
220
+ const withC: Partial<Record<ComponentTag, string>> = { ...components, country }
221
+ const r = random()
222
+
223
+ if (r < 0.8) return { fmt: "full", raw: `${body}, ${country}`, components: withC }
224
+
225
+ if (r < 0.92) return { fmt: "full-nl", raw: `${body}\n${country}`, components: withC }
226
+ const bareBody = order === "us" || order === "fr" ? `${hn} ${street}, ${loc}` : `${street} ${hn}, ${loc}`
227
+
228
+ return {
229
+ fmt: "bare",
230
+ raw: `${bareBody}, ${country}`,
231
+ components: { house_number: hn, street, locality: loc, country },
232
+ }
233
+ }
234
+
235
+ // ── Homograph contrast (the model-first addition) ───────────────────────────────────────────────
236
+ // True country-name homographs: the surface form is BOTH a country AND a US state/locality. Rendering
237
+ // each BOTH ways (foreign-city → country; US-ZIP → region/locality) is what teaches the CONTEXTUAL
238
+ // distinction. role: how the surface reads in US context.
239
+ interface Homograph {
240
+ surface: string
241
+ iso2: string
242
+ cities: readonly string[]
243
+ us: { role: "region" | "locality"; locality: string; region: string; postcodes: readonly string[] }
244
+ }
245
+ const HOMOGRAPHS: readonly Homograph[] = [
246
+ {
247
+ surface: "Georgia",
248
+ iso2: "GE",
249
+ cities: ["Tbilisi", "Batumi", "Kutaisi", "Rustavi"],
250
+ us: { role: "region", locality: "Atlanta", region: "Georgia", postcodes: ["30309", "31401", "30601", "31201"] },
251
+ },
252
+ {
253
+ surface: "Jordan",
254
+ iso2: "JO",
255
+ cities: ["Amman", "Irbid", "Zarqa", "Aqaba"],
256
+ us: { role: "locality", locality: "Jordan", region: "MN", postcodes: ["55352"] },
257
+ },
258
+ {
259
+ surface: "Lebanon",
260
+ iso2: "LB",
261
+ cities: ["Beirut", "Tripoli", "Sidon", "Byblos"],
262
+ us: { role: "locality", locality: "Lebanon", region: "TN", postcodes: ["37087", "03766", "17042", "45036"] },
263
+ },
264
+ {
265
+ surface: "Mexico",
266
+ iso2: "MX",
267
+ cities: ["Guadalajara", "Monterrey", "Puebla", "Oaxaca"],
268
+ us: { role: "locality", locality: "Mexico", region: "MO", postcodes: ["65265"] },
269
+ },
270
+ {
271
+ surface: "Peru",
272
+ iso2: "PE",
273
+ cities: ["Cusco", "Arequipa", "Trujillo", "Iquitos"],
274
+ us: { role: "locality", locality: "Peru", region: "IL", postcodes: ["61354", "46970"] },
275
+ },
276
+ {
277
+ surface: "Turkey",
278
+ iso2: "TR",
279
+ cities: ["Ankara", "Izmir", "Bursa", "Antalya"],
280
+ us: { role: "locality", locality: "Turkey", region: "TX", postcodes: ["79261", "28393"] },
281
+ },
282
+ ]
283
+ // 2-letter codes that are BOTH a US state abbrev AND an ISO country code → must read as region in US ctx.
284
+ interface AbbrevRegion {
285
+ code: string
286
+ localities: readonly string[]
287
+ postcodes: readonly string[]
288
+ }
289
+ const ABBREV_REGIONS: readonly AbbrevRegion[] = [
290
+ { code: "CA", localities: ["Los Angeles", "Sacramento", "San Diego"], postcodes: ["90012", "95814", "92101"] }, // California / Canada
291
+ { code: "GA", localities: ["Atlanta", "Savannah", "Macon"], postcodes: ["30309", "31401", "31201"] }, // Georgia(US) / Georgia
292
+ { code: "IN", localities: ["Indianapolis", "Fort Wayne"], postcodes: ["46204", "46802"] }, // Indiana / India
293
+ { code: "MA", localities: ["Boston", "Worcester"], postcodes: ["02108", "01608"] }, // Massachusetts / Morocco
294
+ { code: "PA", localities: ["Philadelphia", "Pittsburgh"], postcodes: ["19103", "15222"] }, // Pennsylvania / Panama
295
+ { code: "AL", localities: ["Birmingham", "Montgomery"], postcodes: ["35203", "36104"] }, // Alabama / Albania
296
+ ]
297
+ const STREET_POOL: readonly string[] = [
298
+ "Main Street",
299
+ "Oak Avenue",
300
+ "Park Road",
301
+ "Elm Street",
302
+ "Hill Road",
303
+ "Market Street",
304
+ "Church Street",
305
+ "King Street",
306
+ "2nd Avenue",
307
+ "Maple Drive",
308
+ ]
309
+ const pick = <T>(random: () => number, arr: readonly T[]): T => arr[Math.floor(random() * arr.length)]!
310
+ const houseNo = (random: () => number): string => String(1 + Math.floor(random() * 998))
311
+
312
+ /**
313
+ * A homograph CONTRAST row: ~half render the surface as `country` (foreign city), half as the US `region`/`locality`
314
+ * (US ZIP, NO country). Returns iso2 for provenance.
315
+ */
316
+ function renderHomograph(random: () => number): {
317
+ fmt: string
318
+ raw: string
319
+ components: Partial<Record<ComponentTag, string>>
320
+ iso2: string
321
+ } {
322
+ const h = pick(random, HOMOGRAPHS)
323
+ const hn = houseNo(random),
324
+ street = pick(random, STREET_POOL)
325
+
326
+ if (random() < 0.5) {
327
+ const city = pick(random, h.cities)
328
+ const withStreet = random() < 0.6
329
+ const raw = withStreet ? `${hn} ${street}, ${city}, ${h.surface}` : `${city}, ${h.surface}`
330
+ const components: Partial<Record<ComponentTag, string>> = withStreet
331
+ ? { house_number: hn, street, locality: city, country: h.surface }
332
+ : { locality: city, country: h.surface }
333
+
334
+ return { fmt: "homograph-country", raw, components, iso2: h.iso2 }
335
+ }
336
+ const pc = pick(random, h.us.postcodes)
337
+
338
+ if (h.us.role === "region") {
339
+ // surface is the US STATE: "123 Oak Ave, Atlanta, Georgia 30309" → region, no country
340
+ return {
341
+ fmt: "homograph-us-region",
342
+ raw: `${hn} ${street}, ${h.us.locality}, ${h.surface} ${pc}`,
343
+ components: { house_number: hn, street, locality: h.us.locality, region: h.surface, postcode: pc },
344
+ iso2: "US",
345
+ }
346
+ }
347
+
348
+ // surface is the US CITY: "123 Oak Ave, Lebanon, TN 37087" → locality, no country
349
+ return {
350
+ fmt: "homograph-us-locality",
351
+ raw: `${hn} ${street}, ${h.surface}, ${h.us.region} ${pc}`,
352
+ components: { house_number: hn, street, locality: h.surface, region: h.us.region, postcode: pc },
353
+ iso2: "US",
354
+ }
355
+ }
356
+
357
+ /** An abbrev-as-region negative: "123 Main St, Los Angeles, CA 90012" → region CA, NO country. */
358
+ function renderAbbrevRegion(random: () => number): {
359
+ fmt: string
360
+ raw: string
361
+ components: Partial<Record<ComponentTag, string>>
362
+ iso2: string
363
+ } {
364
+ const a = pick(random, ABBREV_REGIONS)
365
+ const hn = houseNo(random),
366
+ street = pick(random, STREET_POOL),
367
+ locality = pick(random, a.localities),
368
+ postcode = pick(random, a.postcodes)
369
+
370
+ return {
371
+ fmt: "abbrev-region",
372
+ raw: `${hn} ${street}, ${locality}, ${a.code} ${postcode}`,
373
+ components: { house_number: hn, street, locality, region: a.code, postcode },
374
+ iso2: "US",
375
+ }
376
+ }
377
+
378
+ const HOMOGRAPH_FRAC = 0.22 // share of rows that are homograph contrast pairs
379
+ const ABBREV_FRAC = 0.08 // share that are code-as-region negatives (cumulative with HOMOGRAPH_FRAC)
380
+
381
+ export const countryBalancedRecipe: ShardRecipe = {
382
+ name: "country-balanced",
383
+ description: "Balanced model-first country rows (#464): OA skeletons + ISO surface forms + homograph contrast pairs",
384
+ mode: "generate",
385
+ options: [{ flag: "--golden", description: "Emit the held-out VT+Berlin eval slice" }],
386
+ async run(opts, write) {
387
+ if (opts.count == null) throw new Error("country-balanced recipe requires --count <N>")
388
+ const count = opts.count
389
+ // Legacy build-country-shard-balanced.mjs seeded mulberry32 with the raw seed: `const random = mulberry32(opts.seed)`.
390
+ const random = makeMulberry32(opts.seed)
391
+ const source = opts.sourceName ?? "synth-country"
392
+ const sources = opts.golden ? EVAL_SOURCES : SOURCES
393
+ const perSource = Math.ceil((count * 3) / sources.length) // over-read; balance locales
394
+
395
+ const pool: CountryTuple[] = []
396
+
397
+ for (const s of sources) {
398
+ const t = readTuples(s, perSource)
399
+ console.error(` ${s.csv} (${s.iso2}): ${t.length} tuples`)
400
+
401
+ for (const x of t) {
402
+ pool.push(x)
403
+ }
404
+ }
405
+
406
+ if (pool.length === 0) {
407
+ throw new Error("No tuples — are the cached OA zips present in /tmp/oa-cache?")
408
+ }
409
+
410
+ let emitted = 0
411
+ let skipped = 0
412
+ let guard = 0
413
+ const N = pool.length
414
+
415
+ while (emitted < count && guard++ < count * 8) {
416
+ // Mix three row types: homograph contrast (the distinction), code-as-region negatives, and the
417
+ // breadth/recall main path (random ISO form on an OA skeleton, ~30% country-absent).
418
+ const roll = random()
419
+ let rendered: { fmt: string; raw: string; components: Partial<Record<ComponentTag, string>> }
420
+ let rowISO2: string
421
+
422
+ if (roll < HOMOGRAPH_FRAC) {
423
+ const h = renderHomograph(random)
424
+ rendered = h
425
+ rowISO2 = h.iso2
426
+ } else if (roll < HOMOGRAPH_FRAC + ABBREV_FRAC) {
427
+ const a = renderAbbrevRegion(random)
428
+ rendered = a
429
+ rowISO2 = a.iso2
430
+ } else {
431
+ const t = pool[Math.floor(random() * N)]!
432
+ const country = pickCountry(random) // may be null → a country-absent negative row
433
+ rendered = renderCountry(random, t, country)
434
+ rowISO2 = t.iso2
435
+
436
+ if (country && !rendered.raw.includes(country)) {
437
+ skipped++
438
+ continue
439
+ }
440
+ }
441
+ const { raw, components } = rendered
442
+ const localeTag = rowISO2 === "US" ? "en-US" : `${rowISO2.toLowerCase()}-${rowISO2}`
443
+
444
+ if (opts.golden) {
445
+ write(JSON.stringify({ raw, components, country: rowISO2 }) + "\n")
446
+ emitted++
447
+ continue
448
+ }
449
+ const canonical: CanonicalRow = {
450
+ raw,
451
+ components,
452
+ country: rowISO2,
453
+ locale: localeTag,
454
+ source,
455
+ source_id: stableSourceID(source, components),
456
+ corpus_version: "0.4.0",
457
+ license: "OpenAddresses multi-locale skeletons + injected ISO-3166 country surface forms (codex)",
458
+ }
459
+ const aligned = alignRow(canonical)
460
+
461
+ if (aligned.kind !== "labeled" || !aligned.row) {
462
+ skipped++
463
+ continue
464
+ }
465
+ write(JSON.stringify({ ...aligned.row, synth_method: "country", synth_base_id: null }) + "\n")
466
+ emitted++
467
+ }
468
+
469
+ return { emitted, skipped }
470
+ },
471
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `cz-pcfirst-preposition` — the Czech pc-first prepositional-locality shard, fourth orthography
7
+ * of the #901 leading-name-boundary family. The #897 close-out read all 8 residual CZ rows as one
8
+ * class: a LEADING postcode mis-assigned as house_number while the multi-word "nad/pod/u X"
9
+ * locality shatters ("51244 Rokytnice nad Jizerou, Dolní Rokytnice 111" → street
10
+ * 'RokytnicenadJizerou' + house_number '51244'). That leading-5-digit confusion is the
11
+ * anchor-pollution class whose decode-time OVERRIDE was correctly killed in #723 — this shard is
12
+ * the model-first fix as DATA: real prepositional localities in the order that breaks, so the
13
+ * model learns that a leading postcode before a multi-word name is a postcode. pc-first leads the
14
+ * cycle (the lesson); canonical and city-first keep the polarity balanced (the v1.9.9 lesson).
15
+ */
16
+
17
+ import { alignAndWrite, makeMulberry32, readTuples, type ShardRecipe, shardSourceID } from "./scaffold.ts"
18
+
19
+ export const czPcFirstPrepositionRecipe: ShardRecipe = {
20
+ name: "cz-pcfirst-preposition",
21
+ description:
22
+ "CZ pc-first + prepositional locality (#901 family): '«pc» «city nad X», «st» «n»' — the #723 class as data",
23
+ mode: "tuples",
24
+ async run(opts, write) {
25
+ makeMulberry32(opts.seed)
26
+ let read = 0
27
+ let emitted = 0
28
+ let skipped = 0
29
+
30
+ for await (const t of readTuples(opts.input!)) {
31
+ read++
32
+ const street = String(t.street ?? "").trim()
33
+ const city = String(t.locality ?? "").trim()
34
+ const number = String(t.number ?? "").trim()
35
+ const postcode = String(t.postcode ?? "").trim()
36
+
37
+ if (!street || !city || !number || !postcode) {
38
+ skipped++
39
+ continue
40
+ }
41
+ const order = read % 3
42
+ let raw: string
43
+ const components: Record<string, string> = {
44
+ street,
45
+ house_number: number,
46
+ postcode,
47
+ locality: city,
48
+ }
49
+
50
+ if (order === 0) {
51
+ raw = `${postcode} ${city}, ${street} ${number}`
52
+ } else if (order === 1) {
53
+ raw = `${street} ${number}, ${postcode} ${city}`
54
+ } else {
55
+ raw = `${city}, ${postcode}, ${street} ${number}`
56
+ }
57
+ const source_id = shardSourceID("synth-cz-pcfirst-preposition", {
58
+ ...components,
59
+ o: String(order),
60
+ v: String(read),
61
+ })
62
+ const canonical = {
63
+ raw,
64
+ components,
65
+ country: "CZ",
66
+ locale: "cs-CZ",
67
+ source: "synth-cz-pcfirst-preposition",
68
+ source_id,
69
+ corpus_version: "0.10.0",
70
+ license:
71
+ "Synthetic — cz-pcfirst-preposition; (street, number, postcode, city) from OpenAddresses CZ (per-source attribution in the model card)",
72
+ }
73
+
74
+ if (alignAndWrite(write, canonical, "cz-pcfirst-preposition")) {
75
+ emitted++
76
+ } else {
77
+ skipped++
78
+ }
79
+ }
80
+
81
+ return { read, emitted, skipped }
82
+ },
83
+ }