@mailwoman/corpus 7.1.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 +15 -7
  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,185 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `geonames`: GeoNames populated-places consumer (https://www.geonames.org/, CC-BY-4.0).
7
+ *
8
+ * GeoNames is a global gazetteer of ~12M features. This adapter ingests the POPULATED PLACES
9
+ * (`feature_class = "P"`, excluding historical/abandoned/destroyed variants) from a per-country
10
+ * dump file — global locality coverage, including the small towns and villages a coarser admin
11
+ * gazetteer (WOF) lacks. It's the cheapest path to broadening the corpus's LOCALE coverage.
12
+ *
13
+ * Input: a per-country tab-separated dump (e.g. `US.txt` from
14
+ * `https://download.geonames.org/export/dump/`, 19 columns, no header). Two sibling files in the
15
+ * same directory supply human-readable names (downloaded once from the same place):
16
+ *
17
+ * - `admin1CodesASCII.txt` — `<CC>.<admin1_code>` → region name (e.g. `US.VT` → "Vermont").
18
+ * - `countryInfo.txt` — ISO alpha-2 → country name (e.g. `US` → "United States"); `#`-commented. If a
19
+ * sibling is missing, the corresponding component is simply omitted (graceful degradation).
20
+ *
21
+ * Output: per place, up to two hierarchy variants (mirroring `wof-admin`'s with/without-country
22
+ * balance so the model sees both domestic and international order) —
23
+ *
24
+ * 1. `{ locality, region }` → "City, Region"
25
+ * 2. `{ locality, region, country }` → "City, Region, Country" `reconcileComponents` drops any
26
+ * component that didn't survive into the rendered `raw`.
27
+ *
28
+ * License: stamped `"CC-BY-4.0"` per row (GeoNames' terms); provenance is the `geonames-<id>` key.
29
+ */
30
+
31
+ import { createReadStream, existsSync, readFileSync } from "node:fs"
32
+ import { dirname, join } from "node:path"
33
+
34
+ import { parse as csvParse } from "csv-parse"
35
+
36
+ import { stableSourceID } from "../../adapter.ts"
37
+ import { reconcileComponents } from "../../format.ts"
38
+ import type { AdapterOptions, CanonicalRow, CorpusAdapter } from "../../types.ts"
39
+
40
+ export const GEONAMES_ADAPTER_ID = "geonames"
41
+ export const GEONAMES_DEFAULT_LICENSE = "CC-BY-4.0"
42
+
43
+ // GeoNames main-table column indices (0-based; see the export README).
44
+ const COL = {
45
+ geonameid: 0,
46
+ name: 1,
47
+ alternatenames: 3,
48
+ featureClass: 6,
49
+ featureCode: 7,
50
+ country: 8,
51
+ admin1: 10,
52
+ } as const
53
+
54
+ // Populated-place feature codes that are NOT current real places — skip them.
55
+ const NON_CURRENT_PPL = new Set(["PPLH", "PPLQ", "PPLW", "PPLCH"])
56
+
57
+ /** Load `admin1CodesASCII.txt` → Map("<CC>.<admin1>" → region name). Empty map if absent. */
58
+ function loadAdmin1(dir: string): Map<string, string> {
59
+ const map = new Map<string, string>()
60
+ const fp = join(dir, "admin1CodesASCII.txt")
61
+
62
+ if (!existsSync(fp)) return map
63
+
64
+ for (const line of readFileSync(fp, "utf8").split("\n")) {
65
+ if (!line) continue
66
+ const cols = line.split("\t")
67
+
68
+ if (cols[0] && cols[1]) {
69
+ map.set(cols[0], cols[1])
70
+ }
71
+ }
72
+
73
+ return map
74
+ }
75
+
76
+ /** Load `countryInfo.txt` → Map(ISO → country name). Empty map if absent. The file is `#`-commented. */
77
+ function loadCountries(dir: string): Map<string, string> {
78
+ const map = new Map<string, string>()
79
+ const fp = join(dir, "countryInfo.txt")
80
+
81
+ if (!existsSync(fp)) return map
82
+
83
+ for (const line of readFileSync(fp, "utf8").split("\n")) {
84
+ if (!line || line.startsWith("#")) continue
85
+ const cols = line.split("\t")
86
+
87
+ // ISO(0), ISO3(1), iso-numeric(2), fips(3), Country(4), ...
88
+ if (cols[0] && cols[4]) {
89
+ map.set(cols[0], cols[4])
90
+ }
91
+ }
92
+
93
+ return map
94
+ }
95
+
96
+ export function createGeonamesAdapter(): CorpusAdapter {
97
+ return {
98
+ id: GEONAMES_ADAPTER_ID,
99
+ defaultLicense: GEONAMES_DEFAULT_LICENSE,
100
+ description:
101
+ "GeoNames populated places (CC-BY-4.0) — global locality coverage incl. small towns, with region/country names from the sibling admin1/countryInfo files.",
102
+
103
+ async *rows(opts: AdapterOptions): AsyncIterable<CanonicalRow> {
104
+ const dir = dirname(opts.inputPath)
105
+ const admin1 = loadAdmin1(dir)
106
+ const countries = loadCountries(dir)
107
+
108
+ const stream = createReadStream(opts.inputPath, { encoding: "utf8" })
109
+ const parser = stream.pipe(
110
+ csvParse({ delimiter: "\t", quote: false, relax_column_count: true, skip_empty_lines: true })
111
+ )
112
+
113
+ let emitted = 0
114
+
115
+ try {
116
+ for await (const rec of parser as AsyncIterable<string[]>) {
117
+ if (opts.signal?.aborted) break
118
+
119
+ if (opts.limit !== undefined && emitted >= opts.limit) break
120
+
121
+ if (rec[COL.featureClass] !== "P") continue
122
+
123
+ if (NON_CURRENT_PPL.has(rec[COL.featureCode] ?? "")) continue
124
+
125
+ const cc = (rec[COL.country] ?? "").trim()
126
+
127
+ if (!cc) continue
128
+
129
+ if (opts.country && cc !== opts.country) continue
130
+
131
+ const locality = (rec[COL.name] ?? "").trim()
132
+
133
+ if (!locality) continue
134
+ const geonameid = (rec[COL.geonameid] ?? "").trim()
135
+ const region = admin1.get(`${cc}.${(rec[COL.admin1] ?? "").trim()}`)
136
+ const country = countries.get(cc)
137
+
138
+ // Two hierarchy variants (domestic + international order) — but only emit the
139
+ // distinct ones the available names support.
140
+ const variants: Array<{ slot: string; comp: CanonicalRow["components"]; raw: string }> = []
141
+
142
+ if (region) {
143
+ variants.push({ slot: "lr", comp: { locality, region }, raw: `${locality}, ${region}` })
144
+
145
+ if (country) {
146
+ variants.push({
147
+ slot: "lrc",
148
+ comp: { locality, region, country },
149
+ raw: `${locality}, ${region}, ${country}`,
150
+ })
151
+ }
152
+ } else if (country) {
153
+ variants.push({ slot: "lc", comp: { locality, country }, raw: `${locality}, ${country}` })
154
+ } else {
155
+ variants.push({ slot: "l", comp: { locality }, raw: locality })
156
+ }
157
+
158
+ for (const v of variants) {
159
+ if (opts.limit !== undefined && emitted >= opts.limit) break
160
+ const aligned = reconcileComponents(v.comp, v.raw)
161
+
162
+ if (Object.keys(aligned).length === 0) continue
163
+ const sourceID = geonameid
164
+ ? `${GEONAMES_ADAPTER_ID}-${geonameid}-${v.slot}`
165
+ : stableSourceID(GEONAMES_ADAPTER_ID, aligned)
166
+ yield {
167
+ raw: v.raw,
168
+ components: aligned,
169
+ country: cc,
170
+ source: GEONAMES_ADAPTER_ID,
171
+ source_id: sourceID,
172
+ corpus_version: "",
173
+ license: GEONAMES_DEFAULT_LICENSE,
174
+ }
175
+ emitted++
176
+ }
177
+ }
178
+ } finally {
179
+ stream.destroy()
180
+ }
181
+ },
182
+ }
183
+ }
184
+
185
+ export const geonamesAdapter = createGeonamesAdapter()
@@ -0,0 +1,111 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `geonames-postal`: GeoNames postal-code dump consumer (https://www.geonames.org/, CC-BY-4.0).
7
+ *
8
+ * The GeoNames postal export (`https://download.geonames.org/export/zip/<CC>.zip`) is a clean,
9
+ * per-country `postcode → place → admin1` table with the place + region NAMES inline (no aux-file
10
+ * join needed). It broadens the corpus's postcode→locality→region coverage to ~80 countries, well
11
+ * beyond `wof-postalcode`/the coordinate-first table — forward coverage for the multi-locale
12
+ * goal.
13
+ *
14
+ * Input: a per-country postal dump (`<CC>.txt`, 12 tab-separated columns, no header): country,
15
+ * postcode, place, admin1_name, admin1_code, admin2__, admin3__, lat, lon, accuracy.
16
+ *
17
+ * Output: per row, postcode-FIRST (international) variants — the common order for the non-US
18
+ * locales this fills (US postcodes are already covered by TIGER/WOF, which use postcode-LAST):
19
+ *
20
+ * 1. `{ postcode, locality }` → "AD100 Canillo"
21
+ * 2. `{ postcode, locality, region }` → "AD100 Canillo, Canillo" Prefer configuring this adapter for
22
+ * non-US countries; for US, the postcode-last sources are the right order. License:
23
+ * `"CC-BY-4.0"` per row (attribute "GeoNames").
24
+ */
25
+
26
+ import { createReadStream } from "node:fs"
27
+
28
+ import { parse as csvParse } from "csv-parse"
29
+
30
+ import { stableSourceID } from "../../adapter.ts"
31
+ import { reconcileComponents } from "../../format.ts"
32
+ import type { AdapterOptions, CanonicalRow, CorpusAdapter } from "../../types.ts"
33
+
34
+ export const GEONAMES_POSTAL_ADAPTER_ID = "geonames-postal"
35
+ export const GEONAMES_POSTAL_DEFAULT_LICENSE = "CC-BY-4.0"
36
+
37
+ // GeoNames postal-dump columns (0-based).
38
+ const COL = { country: 0, postcode: 1, place: 2, admin1Name: 3 } as const
39
+
40
+ export function createGeonamesPostalAdapter(): CorpusAdapter {
41
+ return {
42
+ id: GEONAMES_POSTAL_ADAPTER_ID,
43
+ defaultLicense: GEONAMES_POSTAL_DEFAULT_LICENSE,
44
+ description:
45
+ "GeoNames postal codes (CC-BY-4.0) — multi-locale postcode→locality→region, names inline; international postcode-first order.",
46
+
47
+ async *rows(opts: AdapterOptions): AsyncIterable<CanonicalRow> {
48
+ const stream = createReadStream(opts.inputPath, { encoding: "utf8" })
49
+ const parser = stream.pipe(
50
+ csvParse({ delimiter: "\t", quote: false, relax_column_count: true, skip_empty_lines: true })
51
+ )
52
+
53
+ let emitted = 0
54
+
55
+ try {
56
+ for await (const rec of parser as AsyncIterable<string[]>) {
57
+ if (opts.signal?.aborted) break
58
+
59
+ if (opts.limit !== undefined && emitted >= opts.limit) break
60
+
61
+ const cc = (rec[COL.country] ?? "").trim()
62
+
63
+ if (!cc) continue
64
+
65
+ if (opts.country && cc !== opts.country) continue
66
+
67
+ const postcode = (rec[COL.postcode] ?? "").trim()
68
+ const locality = (rec[COL.place] ?? "").trim()
69
+
70
+ if (!postcode || !locality) continue
71
+ const region = (rec[COL.admin1Name] ?? "").trim()
72
+
73
+ // Postcode-first (international) variants. Skip the region variant when admin1 just
74
+ // repeats the place (common for city-states / micro-admin) to avoid "X X" noise.
75
+ const variants: Array<{ slot: string; comp: CanonicalRow["components"]; raw: string }> = [
76
+ { slot: "pl", comp: { postcode, locality }, raw: `${postcode} ${locality}` },
77
+ ]
78
+
79
+ if (region && region.toLowerCase() !== locality.toLowerCase()) {
80
+ variants.push({
81
+ slot: "plr",
82
+ comp: { postcode, locality, region },
83
+ raw: `${postcode} ${locality}, ${region}`,
84
+ })
85
+ }
86
+
87
+ for (const v of variants) {
88
+ if (opts.limit !== undefined && emitted >= opts.limit) break
89
+ const aligned = reconcileComponents(v.comp, v.raw)
90
+
91
+ if (Object.keys(aligned).length < 2) continue
92
+ yield {
93
+ raw: v.raw,
94
+ components: aligned,
95
+ country: cc,
96
+ source: GEONAMES_POSTAL_ADAPTER_ID,
97
+ source_id: `${stableSourceID(GEONAMES_POSTAL_ADAPTER_ID, aligned)}-${v.slot}`,
98
+ corpus_version: "",
99
+ license: GEONAMES_POSTAL_DEFAULT_LICENSE,
100
+ }
101
+ emitted++
102
+ }
103
+ }
104
+ } finally {
105
+ stream.destroy()
106
+ }
107
+ },
108
+ }
109
+ }
110
+
111
+ export const geonamesPostalAdapter = createGeonamesPostalAdapter()
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * G-NAF (Australia) corpus adapter — the parser-teaching half of #208.
7
+ *
8
+ * The model mis-parses Australian addresses in their native postcode-first / house-number-last
9
+ * order: it tags a leading 4-digit postcode as a house number (its US/EU prior) and swaps street
10
+ * ↔ locality with it. `scripts/eval/au-order-probe.ts` proved this is a word-ORDER coverage gap,
11
+ * not capability — the same addresses parse perfectly in canonical order (65% → 87% @25km if the
12
+ * parse were order-robust). EU survives the same eval because its postcodes are
13
+ * format-distinctive (a hyphenated `26-300` reads as a postcode anywhere); a bare AU `3053` only
14
+ * disambiguates by position.
15
+ *
16
+ * So this adapter renders each assembled G-NAF tuple (from {@link ./assemble}) in one of three real
17
+ * AU layouts — real-AU canonical (number-first, postcode-trailing), postcode-first,
18
+ * locality-first — ROTATED by row index (`i % 3`), so the locality + postcode each land in every
19
+ * position across the shard. This is the exact mechanism that fixed #148's v1.9.0 order-overfit
20
+ * for the 16 EU locales (`scripts/rerender-overture-multiorder.mjs`, v1.9.1 → shipped v4.13.0);
21
+ * AU was simply never in that train (`country_weights` had no AU, and `data_loader.py` excludes
22
+ * unlisted countries). Rotating one order per row (rather than emitting all three) keeps this a
23
+ * clean single-variable extension of the proven recipe + matches its source-mass structure. The
24
+ * corpus aligner BIO-labels each (every component surface form occurs verbatim in `raw`, so
25
+ * alignment lands).
26
+ *
27
+ * Input: the assembled component JSONL (one `{house_number,street,locality,region,postcode}` per
28
+ * line). Open G-NAF licence — attribute "Geoscape Australia".
29
+ */
30
+
31
+ import { TextSpliterator } from "spliterator"
32
+
33
+ import { stableSourceID } from "../../adapter.ts"
34
+ import { reconcileComponents } from "../../format.ts"
35
+ import type { AdapterOptions, CanonicalRow, CorpusAdapter } from "../../types.ts"
36
+
37
+ export const GNAF_ADAPTER_ID = "gnaf"
38
+ /** Open G-NAF is freely redistributable with attribution to Geoscape Australia (CC-BY-style). */
39
+ export const GNAF_DEFAULT_LICENSE = "CC-BY-4.0"
40
+
41
+ interface GNAFTuple {
42
+ house_number: string
43
+ street: string
44
+ locality: string
45
+ region?: string
46
+ postcode: string
47
+ }
48
+
49
+ /**
50
+ * The address layouts an AU address actually arrives in. The model already handles postcode-TRAILING (canonical); the
51
+ * two postcode-LEADING forms are the ones it fails, so they carry the lever. We keep the canonical form too so the
52
+ * retrain doesn't forget it.
53
+ */
54
+ function renderOrders(c: GNAFTuple): string[] {
55
+ const region = c.region ? ` ${c.region}` : ""
56
+
57
+ return [
58
+ // real-AU canonical: number-first, street, suburb [state] postcode — "50 Barry Street, Carlton NSW 2000"
59
+ `${c.house_number} ${c.street}, ${c.locality}${region} ${c.postcode}`,
60
+ // postcode-first (the dominant failure): "2000 Carlton, Barry Street 50"
61
+ `${c.postcode} ${c.locality}, ${c.street} ${c.house_number}`,
62
+ // locality-first: "Carlton, 2000, Barry Street 50"
63
+ `${c.locality}, ${c.postcode}, ${c.street} ${c.house_number}`,
64
+ ]
65
+ }
66
+
67
+ /**
68
+ * Build the G-NAF adapter. `inputPath` is the assembled component JSONL (see {@link ./assemble}); it is country-pinned
69
+ * to AU regardless of `opts.country` (G-NAF is Australia-only).
70
+ */
71
+ export function createGNAFAdapter(): CorpusAdapter {
72
+ return {
73
+ id: GNAF_ADAPTER_ID,
74
+ defaultLicense: GNAF_DEFAULT_LICENSE,
75
+ description:
76
+ "G-NAF (Australia): assembled address tuples rendered in multiple word orders (canonical / postcode-first / locality-first) — teaches the model AU's postcode-first layout.",
77
+
78
+ async *rows(opts: AdapterOptions): AsyncIterable<CanonicalRow> {
79
+ let emitted = 0
80
+ let idx = 0
81
+
82
+ // Input is the assembled component JSONL (one tuple per line). TextSpliterator auto-disposes on
83
+ // loop completion and on an early `break` (abort / limit), so the old explicit handle teardown is
84
+ // gone; JSON.parse tolerates a trailing CR on CRLF sources and the `!line.trim()` guard skips blanks.
85
+ // The render order rotates (i % 3), matching v1.9.1's rerender.
86
+ for await (const line of TextSpliterator.fromAsync(opts.inputPath)) {
87
+ if (opts.signal?.aborted) break
88
+
89
+ if (opts.limit !== undefined && emitted >= opts.limit) break
90
+
91
+ if (!line.trim()) continue
92
+
93
+ let t: GNAFTuple
94
+
95
+ try {
96
+ t = JSON.parse(line) as GNAFTuple
97
+ } catch {
98
+ continue
99
+ }
100
+
101
+ if (!t.house_number || !t.street || !t.locality || !t.postcode) continue
102
+
103
+ const orders = renderOrders(t)
104
+ const order = idx % orders.length
105
+ idx++
106
+ const raw = orders[order]!
107
+ const components: CanonicalRow["components"] = {
108
+ house_number: t.house_number,
109
+ street: t.street,
110
+ locality: t.locality,
111
+ postcode: t.postcode,
112
+ }
113
+
114
+ // region (state) rides only the canonical render (order 0); the postcode-leading layouts
115
+ // omit it (matching the eval's serialization) so it never breaks verbatim alignment.
116
+ if (order === 0 && t.region) {
117
+ components.region = t.region
118
+ }
119
+
120
+ const aligned = reconcileComponents(components, raw)
121
+
122
+ if (Object.keys(aligned).length === 0) continue
123
+ yield {
124
+ raw,
125
+ components: aligned,
126
+ country: "AU",
127
+ locale: "en-AU",
128
+ source: GNAF_ADAPTER_ID,
129
+ source_id: `${stableSourceID(GNAF_ADAPTER_ID, aligned)}-o${order}`,
130
+ corpus_version: "",
131
+ license: GNAF_DEFAULT_LICENSE,
132
+ }
133
+ emitted++
134
+ }
135
+ },
136
+ }
137
+ }
138
+
139
+ export const gnafAdapter = createGNAFAdapter()
@@ -0,0 +1,200 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Assemble a sampled, component-labeled Australian address set from G-NAF (the Geocoded National
7
+ * Address File — Geoscape Australia, Open G-NAF licence; any derived artifact must attribute
8
+ * "Geoscape Australia"). G-NAF is a relational PSV distribution (~16.9M addresses);
9
+ * reconstructing a street address joins three tables — ADDRESS_DETAIL (number, postcode, the
10
+ * PIDs) → STREET_LOCALITY (street name + type) → LOCALITY (suburb). State is the per-file prefix
11
+ * (ACT/NSW/…).
12
+ *
13
+ * Streaming + in-memory join via the house {@link PSVSpliterator} (pipe-separated; `mode: "object"`
14
+ * keys each row by its header) — NOT raw `read_csv` SQL, which a flat-file join doesn't need and
15
+ * which the #183–190 cleanup retired. The two lookup tables (STREET_LOCALITY ~765k rows, LOCALITY
16
+ * ~16k) fit as Maps; ADDRESS_DETAIL is streamed once and reservoir-sampled, so memory stays
17
+ * bounded (the OOM lesson from the Overture ingest).
18
+ *
19
+ * No coordinates: the output feeds the PARSER ({@link ../gnaf/adapter}, #208) — teaching the model
20
+ * AU's postcode-first / house-number-last word order, the gap `scripts/eval/au-order-probe.ts`
21
+ * pinned (65%→87% if the parse were order-robust). The parser needs the address string +
22
+ * component labels, not lat/lon.
23
+ *
24
+ * Output: component tuples as JSONL, consumed by the `gnaf` corpus adapter (which renders them in
25
+ * multiple orders + the corpus aligner BIO-labels them). An optional held-out eval set is
26
+ * excluded by (street, locality, postcode) so the training shard never overlaps the benchmark.
27
+ */
28
+
29
+ import { createWriteStream } from "node:fs"
30
+ import { readdir, readFile } from "node:fs/promises"
31
+ import { join } from "node:path"
32
+
33
+ import { PSVSpliterator } from "spliterator"
34
+
35
+ export interface GNAFAssembleOptions {
36
+ /** G-NAF `Standard` directory (holds the per-state `*_psv.psv` tables). */
37
+ standardDir: string
38
+ /** Target sample size (uniform reservoir → population-proportional across states). */
39
+ sampleSize: number
40
+ /** Output JSONL path. */
41
+ out: string
42
+ /**
43
+ * Optional held-out eval JSONL (rows with a `components` field) — its (street,locality,postcode) are excluded.
44
+ */
45
+ holdoutPath?: string
46
+ /** Progress sink (the CLI passes a setter). */
47
+ onProgress?: (message: string) => void
48
+ }
49
+
50
+ export interface GNAFAssembleResult {
51
+ written: number
52
+ seen: number
53
+ heldOut: number
54
+ byState: Record<string, number>
55
+ }
56
+
57
+ /** UPPERCASE → Title Case, preserving intra-word apostrophes/hyphens (O'Brien, Coff's Harbour). */
58
+ export function titlecase(s: string): string {
59
+ return s
60
+ .toLowerCase()
61
+ .replace(/(^|[\s'\-/])([a-z])/g, (_m, p: string, c: string) => p + c.toUpperCase())
62
+ .trim()
63
+ }
64
+
65
+ /** Holdout/dedup key: a street within a locality+postcode (house-number-agnostic — conservative). */
66
+ export function gnafHoldoutKey(street: string, locality: string, postcode: string): string {
67
+ return `${street}|${locality}|${postcode}`.toLowerCase()
68
+ }
69
+
70
+ type Row = Record<string, string | number | undefined>
71
+ async function* psvObjects(path: string): AsyncIterable<Row> {
72
+ yield* PSVSpliterator.fromAsync(path, { mode: "object", header: true }) as AsyncIterable<Row>
73
+ }
74
+
75
+ /** Load a small lookup table fully into a Map keyed by `keyCol`. */
76
+ async function loadMap<V>(paths: string[], keyCol: string, pick: (r: Row) => V): Promise<Map<string, V>> {
77
+ const m = new Map<string, V>()
78
+
79
+ for (const p of paths) {
80
+ for await (const r of psvObjects(p)) {
81
+ const k = r[keyCol]
82
+
83
+ if (k != null && k !== "") {
84
+ m.set(String(k), pick(r))
85
+ }
86
+ }
87
+ }
88
+
89
+ return m
90
+ }
91
+
92
+ /** Build the held-out key set from an eval JSONL whose rows carry a `components` object. */
93
+ async function loadHoldout(path: string): Promise<Set<string>> {
94
+ const keys = new Set<string>()
95
+ const text = await readFile(path, "utf8")
96
+
97
+ for (const line of text.split("\n")) {
98
+ if (!line.trim()) continue
99
+
100
+ try {
101
+ const c = (JSON.parse(line) as { components?: Record<string, string> }).components
102
+
103
+ if (c?.street && c?.locality && c?.postcode) {
104
+ keys.add(gnafHoldoutKey(c.street, c.locality, c.postcode))
105
+ }
106
+ } catch {
107
+ /* skip malformed */
108
+ }
109
+ }
110
+
111
+ return keys
112
+ }
113
+
114
+ export async function assembleGNAF(opts: GNAFAssembleOptions): Promise<GNAFAssembleResult> {
115
+ const progress = opts.onProgress ?? (() => {})
116
+ const files = await readdir(opts.standardDir)
117
+ const pick = (re: RegExp, exclude?: RegExp) =>
118
+ files.filter((f) => re.test(f) && !(exclude && exclude.test(f))).map((f) => join(opts.standardDir, f))
119
+
120
+ // `*_LOCALITY_psv.psv` also globs `*_STREET_LOCALITY_psv.psv` — exclude the latter explicitly.
121
+ const streetPaths = pick(/_STREET_LOCALITY_psv\.psv$/)
122
+ const localityPaths = pick(/_LOCALITY_psv\.psv$/, /_STREET_LOCALITY_psv\.psv$/)
123
+ const addressPaths = pick(/_ADDRESS_DETAIL_psv\.psv$/)
124
+
125
+ const holdout = opts.holdoutPath ? await loadHoldout(opts.holdoutPath) : new Set<string>()
126
+
127
+ if (opts.holdoutPath) {
128
+ progress(`held-out eval keys: ${holdout.size}`)
129
+ }
130
+
131
+ progress(`loading STREET_LOCALITY (${streetPaths.length} files) + LOCALITY (${localityPaths.length})…`)
132
+ const streetMap = await loadMap(streetPaths, "STREET_LOCALITY_PID", (r) => ({
133
+ name: String(r.STREET_NAME ?? ""),
134
+ type: String(r.STREET_TYPE_CODE ?? ""),
135
+ suffix: String(r.STREET_SUFFIX_CODE ?? ""),
136
+ }))
137
+ const localityMap = await loadMap(localityPaths, "LOCALITY_PID", (r) => String(r.LOCALITY_NAME ?? ""))
138
+ progress(`streets=${streetMap.size.toLocaleString()} localities=${localityMap.size.toLocaleString()}`)
139
+
140
+ const reservoir: Array<{ house_number: string; street: string; locality: string; region: string; postcode: string }> =
141
+ []
142
+ let seen = 0
143
+ let heldOut = 0
144
+
145
+ for (const p of addressPaths) {
146
+ const state = (p.match(/\/([A-Z]+)_ADDRESS_DETAIL/) ?? [])[1] ?? ""
147
+
148
+ for await (const r of psvObjects(p)) {
149
+ const numberFirst = String(r.NUMBER_FIRST ?? "")
150
+
151
+ if (!numberFirst || r.DATE_RETIRED || !r.POSTCODE) continue
152
+ const st = streetMap.get(String(r.STREET_LOCALITY_PID ?? ""))
153
+ const suburbRaw = localityMap.get(String(r.LOCALITY_PID ?? ""))
154
+
155
+ if (!st?.name || !suburbRaw) continue
156
+ const street = `${titlecase(st.name)} ${titlecase(st.type)}${st.suffix ? " " + titlecase(st.suffix) : ""}`.trim()
157
+ const locality = titlecase(suburbRaw)
158
+ const postcode = String(r.POSTCODE)
159
+
160
+ if (holdout.has(gnafHoldoutKey(street, locality, postcode))) {
161
+ heldOut++
162
+ continue
163
+ }
164
+ let house = numberFirst + (r.NUMBER_FIRST_SUFFIX ? String(r.NUMBER_FIRST_SUFFIX) : "")
165
+
166
+ if (r.NUMBER_LAST) {
167
+ house = `${house}-${String(r.NUMBER_LAST)}`
168
+ }
169
+
170
+ if (r.FLAT_NUMBER) {
171
+ house = `${String(r.FLAT_NUMBER)}/${house}`
172
+ }
173
+ const tuple = { house_number: house, street, locality, region: state, postcode }
174
+ seen++
175
+
176
+ if (reservoir.length < opts.sampleSize) {
177
+ reservoir.push(tuple)
178
+ } else {
179
+ const j = Math.floor(Math.random() * seen)
180
+
181
+ if (j < opts.sampleSize) {
182
+ reservoir[j] = tuple
183
+ }
184
+ }
185
+ }
186
+ progress(`${state}: ${seen.toLocaleString()} valid joinable seen`)
187
+ }
188
+
189
+ const out = createWriteStream(opts.out)
190
+ const byState: Record<string, number> = {}
191
+
192
+ for (const t of reservoir) {
193
+ out.write(JSON.stringify(t) + "\n")
194
+ byState[t.region] = (byState[t.region] ?? 0) + 1
195
+ }
196
+ await new Promise<void>((res) => out.end(res))
197
+ progress(`wrote ${reservoir.length.toLocaleString()} tuples → ${opts.out}`)
198
+
199
+ return { written: reservoir.length, seen, heldOut, byState }
200
+ }