@mailwoman/corpus 7.4.0 → 7.6.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.
- package/out/src/shard-recipes/fr-lieudit.d.ts +28 -0
- package/out/src/shard-recipes/fr-lieudit.d.ts.map +1 -0
- package/out/src/shard-recipes/fr-lieudit.js +212 -0
- package/out/src/shard-recipes/fr-lieudit.js.map +1 -0
- package/out/src/shard-recipes/index.d.ts.map +1 -1
- package/out/src/shard-recipes/index.js +2 -0
- package/out/src/shard-recipes/index.js.map +1 -1
- package/out/src/shard-recipes/locale.d.ts +50 -1
- package/out/src/shard-recipes/locale.d.ts.map +1 -1
- package/out/src/shard-recipes/locale.js +139 -18
- package/out/src/shard-recipes/locale.js.map +1 -1
- package/out/src/shard-recipes/scaffold.d.ts +12 -0
- package/out/src/shard-recipes/scaffold.d.ts.map +1 -1
- package/out/src/tools/fetch/ppd.d.ts +16 -0
- package/out/src/tools/fetch/ppd.d.ts.map +1 -0
- package/out/src/tools/fetch/ppd.js +89 -0
- package/out/src/tools/fetch/ppd.js.map +1 -0
- package/out/src/tools/gb-title-case.d.ts +2 -0
- package/out/src/tools/gb-title-case.d.ts.map +1 -0
- package/out/src/tools/gb-title-case.js +65 -0
- package/out/src/tools/gb-title-case.js.map +1 -0
- package/package.json +5 -4
- package/src/shard-recipes/fr-lieudit.ts +274 -0
- package/src/shard-recipes/index.ts +2 -0
- package/src/shard-recipes/locale.ts +181 -20
- package/src/shard-recipes/scaffold.ts +12 -0
- package/src/tools/fetch/ppd.ts +118 -0
- package/src/tools/gb-title-case.ts +68 -0
|
@@ -28,12 +28,13 @@
|
|
|
28
28
|
import { spawn } from "node:child_process"
|
|
29
29
|
import { createReadStream } from "node:fs"
|
|
30
30
|
|
|
31
|
+
import { COUNTRY_SURFACE_FORMS } from "@mailwoman/codex/country"
|
|
31
32
|
import { dataRootPath } from "@mailwoman/core/utils"
|
|
32
33
|
import { CSVSpliterator } from "spliterator"
|
|
33
34
|
|
|
34
35
|
import { stableSourceID } from "../adapter.ts"
|
|
35
36
|
import { alignRow } from "../align.ts"
|
|
36
|
-
import { type LocaleBaseTuple, synthesizeLocaleRow } from "../synthesize-german.ts"
|
|
37
|
+
import { type LocaleBaseTuple, type SynthesizedLocaleRow, synthesizeLocaleRow } from "../synthesize-german.ts"
|
|
37
38
|
import { makeMulberry32, type ShardRecipe } from "./scaffold.ts"
|
|
38
39
|
|
|
39
40
|
/**
|
|
@@ -53,9 +54,19 @@ export interface LocalePart {
|
|
|
53
54
|
* rows). Without this, the default CITY→locality mapping wrongly trains the suburb as the locality.
|
|
54
55
|
*/
|
|
55
56
|
districtAsLocality?: boolean
|
|
57
|
+
/**
|
|
58
|
+
* ES pedanía part only — the header is the RAW (un-conformed) CNIG export schema (`numero`, `tipo_vial`,
|
|
59
|
+
* `nombre_via`, `poblacion`, `municipio`, `comunidad_autonoma`, `cod_postal`), NOT the standard OA
|
|
60
|
+
* NUMBER/STREET/CITY/DISTRICT/REGION/POSTCODE header every other part uses. Verified 2026-07-22 by exact- coordinate
|
|
61
|
+
* cross-check: the OA-conformed `extracted/es/countrywide.csv` collapses CITY to `municipio` and drops `poblacion`
|
|
62
|
+
* (Spain's below-municipio núcleo/pedanía name) entirely, so the pedanía signal survives ONLY in this raw export.
|
|
63
|
+
* `street` is reconstructed as `tipo_vial + " " + nombre_via` (verified byte-identical to the conformed STREET column
|
|
64
|
+
* for the same row). CITY-analog = `poblacion`, DISTRICT-analog = `municipio`.
|
|
65
|
+
*/
|
|
66
|
+
cnigRaw?: boolean
|
|
56
67
|
}
|
|
57
68
|
|
|
58
|
-
interface LocaleCountrySource {
|
|
69
|
+
export interface LocaleCountrySource {
|
|
59
70
|
source: string
|
|
60
71
|
parts: LocalePart[]
|
|
61
72
|
/**
|
|
@@ -63,6 +74,15 @@ interface LocaleCountrySource {
|
|
|
63
74
|
* stay lineage-identical); ES/IT/NL are the #241 staging lineage (`v0.9.9-es-it-nl`).
|
|
64
75
|
*/
|
|
65
76
|
corpusVersion: string
|
|
77
|
+
/**
|
|
78
|
+
* An ALTERNATE part list, read instead of {@link parts} when the `--district-as-locality` override is explicitly
|
|
79
|
+
* `true` for this invocation (see `run()`). ES-only for now — the standard `parts` entry can't supply real
|
|
80
|
+
* dependent-locality signal (its OA-conformed CSV drops `poblacion`; see {@link LocalePart.cnigRaw}), so the pedanía
|
|
81
|
+
* build reads a wholly different raw source instead of flipping the standard CITY/DISTRICT columns. `undefined` for
|
|
82
|
+
* every other country — the override then just forces `districtAsLocality` on the normal `parts`, as GB/NZ already do
|
|
83
|
+
* per-part.
|
|
84
|
+
*/
|
|
85
|
+
pedaniaParts?: LocalePart[]
|
|
66
86
|
}
|
|
67
87
|
|
|
68
88
|
/**
|
|
@@ -101,6 +121,18 @@ const COUNTRY_SOURCES: Record<string, LocaleCountrySource> = {
|
|
|
101
121
|
source: "synth-es",
|
|
102
122
|
corpusVersion: "0.9.9",
|
|
103
123
|
parts: [{ path: dataRootPath("openaddresses", "extracted", "es", "countrywide.csv") }],
|
|
124
|
+
// Pedanía shard (`synth-es-pedania`, `--district-as-locality`). Reads the RAW (un-conformed) CNIG export
|
|
125
|
+
// cached at oa-cache/es__countrywide.zip — the `parts` CSV above lost `poblacion` in OA's own conform step
|
|
126
|
+
// (see {@link LocalePart.cnigRaw}). districtAsLocality is pinned true here (this part only exists to be
|
|
127
|
+
// read pedanía-style); the CLI override still applies harmlessly on top.
|
|
128
|
+
pedaniaParts: [
|
|
129
|
+
{
|
|
130
|
+
zip: dataRootPath("oa-cache", "es__countrywide.zip"),
|
|
131
|
+
csv: "es_addresses.csv",
|
|
132
|
+
cnigRaw: true,
|
|
133
|
+
districtAsLocality: true,
|
|
134
|
+
},
|
|
135
|
+
],
|
|
104
136
|
},
|
|
105
137
|
NZ: {
|
|
106
138
|
// LINZ-derived OA countrywide extract (2.12M rows). `districtAsLocality` inverts the CITY/DISTRICT
|
|
@@ -111,6 +143,16 @@ const COUNTRY_SOURCES: Record<string, LocaleCountrySource> = {
|
|
|
111
143
|
// coord board; that split is a BUILD-TIME concern (scratchpad), so the committed recipe reads the full CSV.
|
|
112
144
|
parts: [{ path: dataRootPath("openaddresses", "extracted", "nz", "countrywide.csv"), districtAsLocality: true }],
|
|
113
145
|
},
|
|
146
|
+
GB: {
|
|
147
|
+
// HM Land Registry Price Paid Data tuples (25.67M rows; see Task 2's ppd ingest). PPD's DISTRICT is the
|
|
148
|
+
// postal town (locality) and CITY is the dependent locality — legitimately EMPTY on the majority of rows
|
|
149
|
+
// (most GB addresses have no dependent locality). `districtAsLocality` maps DISTRICT→locality and, when
|
|
150
|
+
// present, CITY→dependent_locality; the `readTuples` gate above only drops a row when BOTH are empty, so
|
|
151
|
+
// the majority empty-CITY rows survive.
|
|
152
|
+
source: "synth-gb",
|
|
153
|
+
corpusVersion: "0.9.9",
|
|
154
|
+
parts: [{ path: dataRootPath("ppd", "2026-07-22", "gb-tuples.csv"), districtAsLocality: true }],
|
|
155
|
+
},
|
|
114
156
|
}
|
|
115
157
|
|
|
116
158
|
/**
|
|
@@ -172,6 +214,11 @@ interface ColumnIndex {
|
|
|
172
214
|
district: number
|
|
173
215
|
region: number
|
|
174
216
|
post: number
|
|
217
|
+
/**
|
|
218
|
+
* {@link LocalePart.cnigRaw} only — the road-type column (`tipo_vial`) joined onto `street` (`nombre_via`). -1
|
|
219
|
+
* otherwise.
|
|
220
|
+
*/
|
|
221
|
+
tipoVial: number
|
|
175
222
|
}
|
|
176
223
|
|
|
177
224
|
/**
|
|
@@ -217,41 +264,82 @@ export async function readTuples(part: LocalePart, rng: () => number): Promise<L
|
|
|
217
264
|
if (header === null) {
|
|
218
265
|
header = cells.map((h) => h.trim().toLowerCase())
|
|
219
266
|
const ix = (name: string): number => header!.indexOf(name)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
267
|
+
// `cnigRaw` (ES pedanía only): the RAW CNIG header has no NUMBER/STREET/CITY/DISTRICT/REGION/POSTCODE
|
|
268
|
+
// at all — `numero`/`nombre_via`/`poblacion`/`municipio`/`comunidad_autonoma`/`cod_postal` instead.
|
|
269
|
+
// See {@link LocalePart.cnigRaw}.
|
|
270
|
+
cols = part.cnigRaw
|
|
271
|
+
? {
|
|
272
|
+
num: ix("numero"),
|
|
273
|
+
street: ix("nombre_via"),
|
|
274
|
+
tipoVial: ix("tipo_vial"),
|
|
275
|
+
city: ix("poblacion"),
|
|
276
|
+
district: ix("municipio"),
|
|
277
|
+
region: ix("comunidad_autonoma"),
|
|
278
|
+
post: ix("cod_postal"),
|
|
279
|
+
}
|
|
280
|
+
: {
|
|
281
|
+
num: ix("number"),
|
|
282
|
+
street: ix("street"),
|
|
283
|
+
tipoVial: -1,
|
|
284
|
+
city: ix("city"),
|
|
285
|
+
district: ix("district"),
|
|
286
|
+
region: ix("region"),
|
|
287
|
+
post: ix("postcode"),
|
|
288
|
+
}
|
|
228
289
|
|
|
229
290
|
continue
|
|
230
291
|
}
|
|
231
292
|
|
|
232
293
|
if (cols === null) continue
|
|
233
|
-
|
|
294
|
+
// cnigRaw: STREET is split into a road-type column (`tipo_vial`, e.g. "CARRETERA") and the name
|
|
295
|
+
// (`nombre_via`) — rejoin them exactly as OA's own conform step does (verified byte-identical to the
|
|
296
|
+
// conformed STREET column for the same source row). Every other part's header already carries the
|
|
297
|
+
// pre-joined STREET column, so `cols.tipoVial === -1` and this is a no-op there.
|
|
298
|
+
const street =
|
|
299
|
+
cols.tipoVial >= 0
|
|
300
|
+
? [get(cells, cols.tipoVial), get(cells, cols.street)].filter(Boolean).join(" ")
|
|
301
|
+
: get(cells, cols.street)
|
|
234
302
|
const rawCity = get(cells, cols.city)
|
|
235
303
|
|
|
236
|
-
if (!street
|
|
304
|
+
if (!street) continue
|
|
237
305
|
|
|
238
|
-
// Default: CITY → locality. NZ (`districtAsLocality`) inverts it — the OA DISTRICT holds the city
|
|
306
|
+
// Default: CITY → locality. NZ/GB (`districtAsLocality`) inverts it — the OA DISTRICT holds the city
|
|
239
307
|
// (`Auckland`) and CITY holds the suburb (`Birkenhead`), so DISTRICT → locality and CITY →
|
|
240
308
|
// dependent_locality. When DISTRICT is empty (~18% of NZ rows), fall back to CITY → locality with no
|
|
241
|
-
// sub-locality.
|
|
309
|
+
// sub-locality. GB PPD tuples flip which side is legitimately empty — on the MAJORITY of GB rows CITY
|
|
310
|
+
// (the dependent_locality) is empty and DISTRICT (the locality) is populated, so the gate below only
|
|
311
|
+
// drops a `districtAsLocality` row when BOTH are empty, not when CITY alone is (that used to silently
|
|
312
|
+
// drop most of the GB source — see the fixed `readTuples` gate below). See
|
|
313
|
+
// {@link LocalePart.districtAsLocality}.
|
|
242
314
|
let locality: string | null
|
|
243
315
|
let dependent_locality: string | undefined
|
|
244
316
|
|
|
245
317
|
if (part.districtAsLocality) {
|
|
246
|
-
const
|
|
318
|
+
const rawDistrict = get(cells, cols.district)
|
|
319
|
+
|
|
320
|
+
if (!rawCity && !rawDistrict) continue
|
|
321
|
+
|
|
322
|
+
const cleanedDistrict = cleanCityNoise(rawDistrict)
|
|
247
323
|
|
|
248
324
|
if (cleanedDistrict) {
|
|
249
325
|
locality = cleanedDistrict
|
|
250
|
-
|
|
326
|
+
const cleanedCity = cleanCityNoise(rawCity)
|
|
327
|
+
// ES pedanía lesson (2026-07-22): the CNIG `poblacion` column is filled on ~93% of rows but
|
|
328
|
+
// EQUALS `municipio` on the majority of those (the address point sits in the municipio's own
|
|
329
|
+
// main town, not a below-municipio pedanía) — only ~32.6% of ES rows carry a genuinely
|
|
330
|
+
// DISTINCT poblacion. GB/NZ never hit this (CITY/DISTRICT name the same place only by rare
|
|
331
|
+
// coincidence), but the guard is general: a dependent_locality equal to its own locality is
|
|
332
|
+
// never a real sub-locality, so drop it rather than emit a same-value pair (would fail the
|
|
333
|
+
// dep_loc≠locality invariant every recipe otherwise upholds).
|
|
334
|
+
dependent_locality =
|
|
335
|
+
cleanedCity && cleanedCity.localeCompare(locality, undefined, { sensitivity: "base" }) !== 0
|
|
336
|
+
? cleanedCity
|
|
337
|
+
: undefined
|
|
251
338
|
} else {
|
|
252
339
|
locality = cleanCityNoise(rawCity)
|
|
253
340
|
}
|
|
254
341
|
} else {
|
|
342
|
+
if (!rawCity) continue
|
|
255
343
|
locality = cleanCityNoise(rawCity)
|
|
256
344
|
}
|
|
257
345
|
|
|
@@ -292,13 +380,72 @@ export async function readTuples(part: LocalePart, rng: () => number): Promise<L
|
|
|
292
380
|
return reservoir
|
|
293
381
|
}
|
|
294
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Country-append fraction (the fr-admin-split #728 pattern, generalized to the locale recipe): mutates `synth` in
|
|
385
|
+
* place, `countryFraction` of the time appending an explicit country surface form ("United Kingdom") to `raw` + a
|
|
386
|
+
* `country` component — the model relearns to emit country WHEN the token is present without over-firing it on the
|
|
387
|
+
* (still-majority) country-less rows. `countryFraction <= 0` (the default) short-circuits the `random()` draw away
|
|
388
|
+
* entirely — no `synth` mutation and no RNG consumption — so every existing locale's emit stream stays byte-identical
|
|
389
|
+
* to before this option existed. Exported for {@link locale.test.ts}.
|
|
390
|
+
*/
|
|
391
|
+
export function applyCountryAppend(
|
|
392
|
+
synth: SynthesizedLocaleRow,
|
|
393
|
+
country: string,
|
|
394
|
+
countryFraction: number,
|
|
395
|
+
random: () => number
|
|
396
|
+
): void {
|
|
397
|
+
if (countryFraction > 0 && random() < countryFraction) {
|
|
398
|
+
const forms = COUNTRY_SURFACE_FORMS[country as keyof typeof COUNTRY_SURFACE_FORMS]
|
|
399
|
+
|
|
400
|
+
if (!forms?.length) {
|
|
401
|
+
// The BR/NZ lesson: a missing table entry must never silently no-op a requested fraction —
|
|
402
|
+
// it must raise so the gap is caught at build time, not discovered later as a 0% gate failure.
|
|
403
|
+
throw new Error(
|
|
404
|
+
`No COUNTRY_SURFACE_FORMS entry for ${country} — add it to codex/country/country.ts before using --country-fraction`
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const form = forms[Math.floor(random() * forms.length)]!
|
|
409
|
+
synth.raw = `${synth.raw}, ${form}`
|
|
410
|
+
synth.components = { ...synth.components, country: form }
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Merge the `--district-as-locality` CLI override onto one part. `undefined` (flag absent) returns `part` unchanged
|
|
416
|
+
* (same object — no allocation, no behavior change); `true`/`false` returns a shallow copy with `districtAsLocality`
|
|
417
|
+
* forced to that value for this invocation only. Exported for {@link locale.test.ts}.
|
|
418
|
+
*/
|
|
419
|
+
export function applyDistrictAsLocalityOverride(part: LocalePart, override: boolean | undefined): LocalePart {
|
|
420
|
+
return override === undefined ? part : { ...part, districtAsLocality: override }
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Pick which part list a `--country` run reads: {@link LocaleCountrySource.pedaniaParts} when the override is explicitly
|
|
425
|
+
* `true` AND the country registers one (ES only, so far), else the default `parts` — unchanged for every other
|
|
426
|
+
* country/override combination. Exported for {@link locale.test.ts}.
|
|
427
|
+
*/
|
|
428
|
+
export function resolveLocaleParts(countrySource: LocaleCountrySource, override: boolean | undefined): LocalePart[] {
|
|
429
|
+
return override === true && countrySource.pedaniaParts ? countrySource.pedaniaParts : countrySource.parts
|
|
430
|
+
}
|
|
431
|
+
|
|
295
432
|
export const localeRecipe: ShardRecipe = {
|
|
296
433
|
name: "locale",
|
|
297
|
-
description: "Per-locale coverage rows (DE/FR/NL/IT/ES) from real OA tuples, both orders → synthesizeLocaleRow",
|
|
434
|
+
description: "Per-locale coverage rows (DE/FR/NL/IT/ES/NZ/GB) from real OA tuples, both orders → synthesizeLocaleRow",
|
|
298
435
|
mode: "generate",
|
|
299
436
|
options: [
|
|
300
|
-
{ flag: "--country <cc>", description: "Target country (DE|FR|NL|IT|ES|NZ). Default DE" },
|
|
437
|
+
{ flag: "--country <cc>", description: "Target country (DE|FR|NL|IT|ES|NZ|GB). Default DE" },
|
|
301
438
|
{ flag: "--intl-fraction <f>", description: "Fraction rendered international order. Default 0.4" },
|
|
439
|
+
{
|
|
440
|
+
flag: "--country-fraction <f>",
|
|
441
|
+
description:
|
|
442
|
+
"Fraction of rows that append an explicit country surface form (`, United Kingdom`) + a `country` component (fr-admin-split pattern). Default 0 — byte-identical to before when unset.",
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
flag: "--district-as-locality / --no-district-as-locality",
|
|
446
|
+
description:
|
|
447
|
+
"Override the per-part districtAsLocality mapping for this run. Unset (default) leaves each COUNTRY_SOURCES part's own value untouched — every existing build stays byte-identical. ES additionally switches to the pedanía (poblacion→dependent_locality) source when passed as true — combine with --source-name synth-es-pedania.",
|
|
448
|
+
},
|
|
302
449
|
],
|
|
303
450
|
async run(opts, write) {
|
|
304
451
|
// Emit PRNG: the legacy build-locale-shard.mjs seeded mulberry32(opts.seed). The reservoir uses a
|
|
@@ -317,9 +464,21 @@ export const localeRecipe: ShardRecipe = {
|
|
|
317
464
|
if (!(intlFraction >= 0 && intlFraction <= 1)) {
|
|
318
465
|
throw new Error(`--intl-fraction must be in [0, 1], got ${intlFraction}`)
|
|
319
466
|
}
|
|
467
|
+
// Default 0 → the `random() < countryFraction` draw below is short-circuited away entirely (never
|
|
468
|
+
// consumed), so every existing locale's emit stream is byte-identical to before this option existed.
|
|
469
|
+
const countryFraction = opts.countryFraction ?? 0
|
|
470
|
+
|
|
471
|
+
if (!(countryFraction >= 0 && countryFraction <= 1)) {
|
|
472
|
+
throw new Error(`--country-fraction must be in [0, 1], got ${countryFraction}`)
|
|
473
|
+
}
|
|
320
474
|
const source = opts.sourceName ?? countrySource.source
|
|
321
475
|
const count = opts.count ?? 4000
|
|
322
|
-
|
|
476
|
+
// Tri-state: `undefined` (flag absent) touches nothing below — `parts` stays the default list and each
|
|
477
|
+
// part keeps its own `districtAsLocality`, so every existing locale build is byte-identical to before this
|
|
478
|
+
// option existed. `true` additionally selects `pedaniaParts` when the country registers one (ES); `false`
|
|
479
|
+
// forces the mapping off on every part read this run (a debugging escape hatch for GB/NZ).
|
|
480
|
+
const districtAsLocalityOverride = opts.districtAsLocality
|
|
481
|
+
const parts = resolveLocaleParts(countrySource, districtAsLocalityOverride)
|
|
323
482
|
|
|
324
483
|
const pool: LocaleBaseTuple[] = []
|
|
325
484
|
|
|
@@ -327,7 +486,8 @@ export const localeRecipe: ShardRecipe = {
|
|
|
327
486
|
// A reservoir PRNG per part, seeded but independent of the emit loop's `random`, so the sample is
|
|
328
487
|
// reproducible without perturbing the synth/order draws.
|
|
329
488
|
const reservoirRng = makeMulberry32((opts.seed ^ (0x9e3779b9 * (pi + 1))) >>> 0)
|
|
330
|
-
const
|
|
489
|
+
const effectivePart = applyDistrictAsLocalityOverride(parts[pi]!, districtAsLocalityOverride)
|
|
490
|
+
const t = await readTuples(effectivePart, reservoirRng)
|
|
331
491
|
|
|
332
492
|
for (const x of t) {
|
|
333
493
|
pool.push(x)
|
|
@@ -362,6 +522,7 @@ export const localeRecipe: ShardRecipe = {
|
|
|
362
522
|
skipped++
|
|
363
523
|
continue
|
|
364
524
|
}
|
|
525
|
+
applyCountryAppend(synth, country, countryFraction, random)
|
|
365
526
|
|
|
366
527
|
if (opts.golden) {
|
|
367
528
|
// Golden rows must round-trip through alignRow exactly like training rows (#241 done-when): a
|
|
@@ -130,9 +130,21 @@ export interface ShardRecipeOpts {
|
|
|
130
130
|
edgesDir?: string
|
|
131
131
|
country?: string
|
|
132
132
|
intlFraction?: number
|
|
133
|
+
/** `locale`: fraction of rows that append an explicit country surface form + a `country` component. Default 0. */
|
|
134
|
+
countryFraction?: number
|
|
135
|
+
/**
|
|
136
|
+
* `locale`: tri-state override of the per-part `districtAsLocality` mapping for this invocation. `undefined` (flag
|
|
137
|
+
* absent) leaves each `COUNTRY_SOURCES` part's own value untouched — every existing locale build stays
|
|
138
|
+
* byte-identical. `true`/`false` forces that value on every part read this run. ES's pedanía shard
|
|
139
|
+
* (`synth-es-pedania`) additionally uses `true` to select {@link LocaleCountrySource.pedaniaParts} instead of the
|
|
140
|
+
* default `parts` — see `locale.ts`.
|
|
141
|
+
*/
|
|
142
|
+
districtAsLocality?: boolean
|
|
133
143
|
bareProb?: number
|
|
134
144
|
hnProb?: number
|
|
135
145
|
communes?: string
|
|
146
|
+
/** `fr-lieudit`: BAN `adresses-<dept>.csv` directory. Default `$MAILWOMAN_DATA_ROOT/corpus/sources/ban`. */
|
|
147
|
+
banDir?: string
|
|
136
148
|
multilocaleCount?: number
|
|
137
149
|
/**
|
|
138
150
|
* `fr-fragment` / `no-fragment` / `no-street-led`: the eval board's reserved street-surface list. REQUIRED for those
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* HM Land Registry Price Paid Data → OA-shaped GB tuples CSV for the `locale` shard recipe.
|
|
7
|
+
*
|
|
8
|
+
* PPD is E&W-only, ALL-CAPS, and column-structured (no header row). We emit the exact OA header
|
|
9
|
+
* `readTuples` (`shard-recipes/locale.ts`) indexes by name, mapped for `districtAsLocality: true`:
|
|
10
|
+
* CITY = PPD locality (dependent locality; blank when it merely repeats the town — 1995-era rows
|
|
11
|
+
* pad locality=town), DISTRICT = PPD post town, REGION = county. SAON (flat/unit) rows and
|
|
12
|
+
* building-name PAONs are skipped in v1 and counted — `LocaleBaseTuple` has no unit field yet.
|
|
13
|
+
*
|
|
14
|
+
* Snapshot provenance: `$MAILWOMAN_DATA_ROOT/ppd/<date>/pp-complete.csv` (md5 sibling).
|
|
15
|
+
* License: OGL v3 (attribution: HM Land Registry).
|
|
16
|
+
*/
|
|
17
|
+
import { createReadStream, createWriteStream } from "node:fs"
|
|
18
|
+
import { parseArgs } from "node:util"
|
|
19
|
+
|
|
20
|
+
import { runIfScript } from "@mailwoman/core/scripting"
|
|
21
|
+
import { dataRootPath } from "@mailwoman/core/utils"
|
|
22
|
+
import { CSVSpliterator } from "spliterator"
|
|
23
|
+
|
|
24
|
+
import { titleCaseGB } from "../gb-title-case.ts"
|
|
25
|
+
|
|
26
|
+
const HOUSE_NUMBER_PATTERN = /^\d+[A-Za-z]?(\s*-\s*\d+[A-Za-z]?)?$/
|
|
27
|
+
|
|
28
|
+
export interface PPDExtractStats {
|
|
29
|
+
kept: number
|
|
30
|
+
skippedSAON: number
|
|
31
|
+
skippedPAON: number
|
|
32
|
+
skippedNoStreet: number
|
|
33
|
+
skippedNoPostcode: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const quote = (value: string): string => (value ? `"${value.replaceAll('"', '""')}"` : "")
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Convert PPD rows (`id,price,date,postcode,type,new,tenure,PAON,SAON,street,locality,town,district,county,cat,status`
|
|
40
|
+
* — headerless) into OA-shaped tuple lines via `write`, applying the skip rules + title-casing. Accepts a plain array
|
|
41
|
+
* of rows (tests) or a streamed async source (the real 31M-row extraction).
|
|
42
|
+
*/
|
|
43
|
+
export async function extractPPDTuples(
|
|
44
|
+
input: AsyncIterable<string[]> | Iterable<string[]>,
|
|
45
|
+
write: (line: string) => void
|
|
46
|
+
): Promise<PPDExtractStats> {
|
|
47
|
+
const stats: PPDExtractStats = { kept: 0, skippedSAON: 0, skippedPAON: 0, skippedNoStreet: 0, skippedNoPostcode: 0 }
|
|
48
|
+
write("NUMBER,STREET,CITY,DISTRICT,REGION,POSTCODE")
|
|
49
|
+
|
|
50
|
+
for await (const cells of input) {
|
|
51
|
+
const [, , , postcode, , , , paon, saon, street, locality, town, , county] = cells
|
|
52
|
+
|
|
53
|
+
if (saon) {
|
|
54
|
+
stats.skippedSAON++
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!paon || !HOUSE_NUMBER_PATTERN.test(paon)) {
|
|
59
|
+
stats.skippedPAON++
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!street) {
|
|
64
|
+
stats.skippedNoStreet++
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!postcode) {
|
|
69
|
+
stats.skippedNoPostcode++
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const number = paon.replace(/\s*-\s*/, "-")
|
|
74
|
+
const city = locality && locality !== town ? titleCaseGB(locality) : ""
|
|
75
|
+
|
|
76
|
+
write(
|
|
77
|
+
[
|
|
78
|
+
number,
|
|
79
|
+
quote(titleCaseGB(street)),
|
|
80
|
+
quote(city),
|
|
81
|
+
quote(titleCaseGB(town ?? "")),
|
|
82
|
+
quote(titleCaseGB(county ?? "")),
|
|
83
|
+
postcode,
|
|
84
|
+
].join(",")
|
|
85
|
+
)
|
|
86
|
+
stats.kept++
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return stats
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Stream `inputPath` (PPD CSV) → `outputPath` (OA-shaped tuples CSV), returning the row-count stats. */
|
|
93
|
+
export async function runPPDExtract(inputPath: string, outputPath: string): Promise<PPDExtractStats> {
|
|
94
|
+
// No `encoding` — CSVSpliterator delimits raw bytes and decodes utf-8 itself (see readTuples in
|
|
95
|
+
// shard-recipes/locale.ts). `header: false` yields every row as data — PPD ships no header row.
|
|
96
|
+
const rows = CSVSpliterator.fromAsync<string[]>(createReadStream(inputPath), {
|
|
97
|
+
mode: "array",
|
|
98
|
+
header: false,
|
|
99
|
+
enableQuoteHandling: true,
|
|
100
|
+
})
|
|
101
|
+
const out = createWriteStream(outputPath, { encoding: "utf8" })
|
|
102
|
+
const stats = await extractPPDTuples(rows, (line) => out.write(line + "\n"))
|
|
103
|
+
|
|
104
|
+
await new Promise<void>((res) => out.end(res))
|
|
105
|
+
|
|
106
|
+
return stats
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
runIfScript(import.meta, async () => {
|
|
110
|
+
const { values } = parseArgs({
|
|
111
|
+
options: {
|
|
112
|
+
input: { type: "string", default: dataRootPath("ppd", "2026-07-22", "pp-complete.csv") },
|
|
113
|
+
output: { type: "string", default: dataRootPath("ppd", "2026-07-22", "gb-tuples.csv") },
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
const stats = await runPPDExtract(values.input!, values.output!)
|
|
117
|
+
console.log(`[ppd] ${JSON.stringify(stats)}`)
|
|
118
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Title-case an ALL-CAPS GB place/street string (#690 — all-caps is OOD for the model). PPD ships every field
|
|
3
|
+
* upper-case; the model trains on natural casing. Particles (upon, super, next, …) stay lowercase mid-name, both
|
|
4
|
+
* between words and between hyphen segments. Each apostrophe-separated segment is capitalized on its own (D'Arcy,
|
|
5
|
+
* James'), except a bare trailing possessive 's, which stays lowercase (BISHOP'S → Bishop's).
|
|
6
|
+
*/
|
|
7
|
+
const GB_PARTICLES = new Set([
|
|
8
|
+
"upon",
|
|
9
|
+
"on",
|
|
10
|
+
"under",
|
|
11
|
+
"in",
|
|
12
|
+
"by",
|
|
13
|
+
"the",
|
|
14
|
+
"le",
|
|
15
|
+
"la",
|
|
16
|
+
"de",
|
|
17
|
+
"cum",
|
|
18
|
+
"next",
|
|
19
|
+
"with",
|
|
20
|
+
"over",
|
|
21
|
+
"at",
|
|
22
|
+
"super",
|
|
23
|
+
"sub",
|
|
24
|
+
"and",
|
|
25
|
+
"of",
|
|
26
|
+
"y",
|
|
27
|
+
"en",
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
function caseWord(word: string, isFirst: boolean): string {
|
|
31
|
+
if (!word) return word
|
|
32
|
+
const lower = word.toLowerCase()
|
|
33
|
+
|
|
34
|
+
if (!isFirst && GB_PARTICLES.has(lower)) return lower
|
|
35
|
+
|
|
36
|
+
// Capitalize the first letter of each apostrophe-separated segment, except possessive 's.
|
|
37
|
+
return lower
|
|
38
|
+
.split("'")
|
|
39
|
+
.map((segment, idx) => {
|
|
40
|
+
if (!segment) return segment
|
|
41
|
+
|
|
42
|
+
// Don't capitalize a trailing bare possessive 's
|
|
43
|
+
if (idx > 0 && segment === "s") return segment
|
|
44
|
+
|
|
45
|
+
// Capitalize the first letter of this segment
|
|
46
|
+
return segment[0]!.toUpperCase() + segment.slice(1)
|
|
47
|
+
})
|
|
48
|
+
.join("'")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function titleCaseGB(value: string): string {
|
|
52
|
+
value = value.trim()
|
|
53
|
+
let wordIndex = 0
|
|
54
|
+
|
|
55
|
+
return value
|
|
56
|
+
.split(/\s+/)
|
|
57
|
+
.map((token) => {
|
|
58
|
+
const cased = token
|
|
59
|
+
.split("-")
|
|
60
|
+
.map((seg, segIndex) => caseWord(seg, wordIndex === 0 && segIndex === 0))
|
|
61
|
+
.join("-")
|
|
62
|
+
wordIndex += 1
|
|
63
|
+
|
|
64
|
+
return cased
|
|
65
|
+
})
|
|
66
|
+
.join(" ")
|
|
67
|
+
.trim()
|
|
68
|
+
}
|