@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.
- package/package.json +59 -27
- package/src/adapter.ts +153 -0
- package/src/adapters/ban/adapter.ts +189 -0
- package/src/adapters/ban/street-decompose.ts +98 -0
- package/src/adapters/fcc-bdc/adapter.ts +188 -0
- package/src/adapters/geonames/adapter.ts +185 -0
- package/src/adapters/geonames-postal/adapter.ts +111 -0
- package/src/adapters/gnaf/adapter.ts +139 -0
- package/src/adapters/gnaf/assemble.ts +200 -0
- package/src/adapters/index.ts +135 -0
- package/src/adapters/openaddresses/adapter.ts +237 -0
- package/src/adapters/overture/adapter.ts +149 -0
- package/src/adapters/state-hi-schools/adapter.ts +171 -0
- package/src/adapters/state-ia-contractors/adapter.ts +148 -0
- package/src/adapters/state-ny-notaries/adapter.ts +169 -0
- package/src/adapters/state-tx-notaries/adapter.ts +160 -0
- package/src/adapters/synth-po-box/adapter.ts +197 -0
- package/src/adapters/tiger/adapter.ts +232 -0
- package/src/adapters/tiger/street-decompose.ts +120 -0
- package/src/adapters/usgov-hrsa-fqhc/adapter.ts +189 -0
- package/src/adapters/usgov-imls-pls/adapter.ts +152 -0
- package/src/adapters/usgov-irs-bmf/adapter.ts +155 -0
- package/src/adapters/usgov-nad/adapter.ts +338 -0
- package/src/adapters/usgov-nppes/adapter.ts +169 -0
- package/src/adapters/usgov-samhsa-treatment-locator/adapter.ts +211 -0
- package/src/adapters/wof-admin-jp/adapter.ts +176 -0
- package/src/adapters/wof-admin-json/adapter.ts +280 -0
- package/src/adapters/wof-postalcode-json/adapter.ts +209 -0
- package/src/align.ts +317 -0
- package/src/build.ts +348 -0
- package/src/codex/us-fips-state.ts +109 -0
- package/src/format.ts +14 -0
- package/src/golden.ts +147 -0
- package/src/index.ts +28 -0
- package/src/license.ts +48 -0
- package/src/parquet-wrapper/index.ts +9 -0
- package/src/parquet-wrapper/reader.ts +67 -0
- package/src/parquet-wrapper/schema.ts +80 -0
- package/src/parquet-wrapper/writer.ts +102 -0
- package/src/parquet.ts +360 -0
- package/src/runner.ts +283 -0
- package/src/shard-recipes/anchor-absorption.ts +86 -0
- package/src/shard-recipes/boundary-stress.ts +87 -0
- package/src/shard-recipes/country-balanced.ts +471 -0
- package/src/shard-recipes/cz-pcfirst-preposition.ts +83 -0
- package/src/shard-recipes/fr-admin-split.ts +212 -0
- package/src/shard-recipes/fr-bare-street.ts +85 -0
- package/src/shard-recipes/fr-fragment.ts +304 -0
- package/src/shard-recipes/fr-order.ts +283 -0
- package/src/shard-recipes/german.ts +206 -0
- package/src/shard-recipes/house-venue.ts +78 -0
- package/src/shard-recipes/index.ts +76 -0
- package/src/shard-recipes/intersection.ts +617 -0
- package/src/shard-recipes/locale.ts +415 -0
- package/src/shard-recipes/nl-postcode.ts +105 -0
- package/src/shard-recipes/no-fragment.ts +206 -0
- package/src/shard-recipes/no-street-led.ts +130 -0
- package/src/shard-recipes/no-street.ts +78 -0
- package/src/shard-recipes/po-box-cedex.ts +853 -0
- package/src/shard-recipes/po-box.ts +111 -0
- package/src/shard-recipes/scaffold.ts +187 -0
- package/src/shard-recipes/si-bare-village.ts +88 -0
- package/src/shard-recipes/street-affix.ts +549 -0
- package/src/shard-recipes/street-bare.ts +90 -0
- package/src/shard-recipes/street.ts +74 -0
- package/src/shard-recipes/unit.ts +315 -0
- package/src/split.ts +251 -0
- package/src/synthesize-anchor-absorption.ts +263 -0
- package/src/synthesize-boundary-stress.ts +504 -0
- package/src/synthesize-german.ts +227 -0
- package/src/synthesize-house-venue.ts +192 -0
- package/src/synthesize-intersection.ts +205 -0
- package/src/synthesize-no-street.ts +341 -0
- package/src/synthesize-po-box.ts +307 -0
- package/src/synthesize-street.ts +264 -0
- package/src/synthesize.ts +872 -0
- package/src/tokenize.ts +68 -0
- package/src/tools/align-shard.ts +67 -0
- package/src/tools/audit.ts +352 -0
- package/src/tools/corpus-stats.ts +183 -0
- package/src/tools/fetch/ban.ts +288 -0
- package/src/tools/fetch/download.ts +119 -0
- package/src/tools/fetch/hrsa.ts +65 -0
- package/src/tools/fetch/imls-pls.ts +162 -0
- package/src/tools/fetch/index.ts +121 -0
- package/src/tools/fetch/nad.ts +304 -0
- package/src/tools/fetch/nppes.ts +177 -0
- package/src/tools/fetch/openaddresses.ts +380 -0
- package/src/tools/fetch/state-hi-schools.ts +217 -0
- package/src/tools/fetch/state-sources.ts +142 -0
- package/src/tools/fetch/tiger-full.ts +318 -0
- package/src/tools/golden-expand.ts +524 -0
- package/src/tools/golden-promote.ts +304 -0
- package/src/tools/index.ts +24 -0
- package/src/tools/ingest-csv.ts +420 -0
- package/src/tools/jsonl-to-parquet.ts +200 -0
- package/src/tools/lint-shard-vocab.ts +357 -0
- package/src/tools/lint-shard.ts +502 -0
- package/src/tools/overlay-manifest.ts +141 -0
- package/src/tools/shard-kryptonite.ts +131 -0
- package/src/tools/shard-translit.ts +244 -0
- package/src/types.ts +213 -0
- 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
|
+
}
|