@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.
@@ -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
- 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
- }
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
- const street = get(cells, cols.street)
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 || !rawCity) continue
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. See {@link LocalePart.districtAsLocality}.
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 cleanedDistrict = cleanCityNoise(get(cells, cols.district))
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
- dependent_locality = cleanCityNoise(rawCity) ?? undefined
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
- const { parts } = countrySource
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 t = await readTuples(parts[pi]!, reservoirRng)
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
+ }