@mailwoman/corpus 7.2.0 → 7.3.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.
Files changed (103) hide show
  1. package/package.json +59 -27
  2. package/src/adapter.ts +153 -0
  3. package/src/adapters/ban/adapter.ts +189 -0
  4. package/src/adapters/ban/street-decompose.ts +98 -0
  5. package/src/adapters/fcc-bdc/adapter.ts +188 -0
  6. package/src/adapters/geonames/adapter.ts +185 -0
  7. package/src/adapters/geonames-postal/adapter.ts +111 -0
  8. package/src/adapters/gnaf/adapter.ts +139 -0
  9. package/src/adapters/gnaf/assemble.ts +200 -0
  10. package/src/adapters/index.ts +135 -0
  11. package/src/adapters/openaddresses/adapter.ts +237 -0
  12. package/src/adapters/overture/adapter.ts +149 -0
  13. package/src/adapters/state-hi-schools/adapter.ts +171 -0
  14. package/src/adapters/state-ia-contractors/adapter.ts +148 -0
  15. package/src/adapters/state-ny-notaries/adapter.ts +169 -0
  16. package/src/adapters/state-tx-notaries/adapter.ts +160 -0
  17. package/src/adapters/synth-po-box/adapter.ts +197 -0
  18. package/src/adapters/tiger/adapter.ts +232 -0
  19. package/src/adapters/tiger/street-decompose.ts +120 -0
  20. package/src/adapters/usgov-hrsa-fqhc/adapter.ts +189 -0
  21. package/src/adapters/usgov-imls-pls/adapter.ts +152 -0
  22. package/src/adapters/usgov-irs-bmf/adapter.ts +155 -0
  23. package/src/adapters/usgov-nad/adapter.ts +338 -0
  24. package/src/adapters/usgov-nppes/adapter.ts +169 -0
  25. package/src/adapters/usgov-samhsa-treatment-locator/adapter.ts +211 -0
  26. package/src/adapters/wof-admin-jp/adapter.ts +176 -0
  27. package/src/adapters/wof-admin-json/adapter.ts +280 -0
  28. package/src/adapters/wof-postalcode-json/adapter.ts +209 -0
  29. package/src/align.ts +317 -0
  30. package/src/build.ts +348 -0
  31. package/src/codex/us-fips-state.ts +109 -0
  32. package/src/format.ts +14 -0
  33. package/src/golden.ts +147 -0
  34. package/src/index.ts +28 -0
  35. package/src/license.ts +48 -0
  36. package/src/parquet-wrapper/index.ts +9 -0
  37. package/src/parquet-wrapper/reader.ts +67 -0
  38. package/src/parquet-wrapper/schema.ts +80 -0
  39. package/src/parquet-wrapper/writer.ts +102 -0
  40. package/src/parquet.ts +360 -0
  41. package/src/runner.ts +283 -0
  42. package/src/shard-recipes/anchor-absorption.ts +86 -0
  43. package/src/shard-recipes/boundary-stress.ts +87 -0
  44. package/src/shard-recipes/country-balanced.ts +471 -0
  45. package/src/shard-recipes/cz-pcfirst-preposition.ts +83 -0
  46. package/src/shard-recipes/fr-admin-split.ts +212 -0
  47. package/src/shard-recipes/fr-bare-street.ts +85 -0
  48. package/src/shard-recipes/fr-fragment.ts +304 -0
  49. package/src/shard-recipes/fr-order.ts +283 -0
  50. package/src/shard-recipes/german.ts +206 -0
  51. package/src/shard-recipes/house-venue.ts +78 -0
  52. package/src/shard-recipes/index.ts +76 -0
  53. package/src/shard-recipes/intersection.ts +617 -0
  54. package/src/shard-recipes/locale.ts +415 -0
  55. package/src/shard-recipes/nl-postcode.ts +105 -0
  56. package/src/shard-recipes/no-fragment.ts +206 -0
  57. package/src/shard-recipes/no-street-led.ts +130 -0
  58. package/src/shard-recipes/no-street.ts +78 -0
  59. package/src/shard-recipes/po-box-cedex.ts +853 -0
  60. package/src/shard-recipes/po-box.ts +111 -0
  61. package/src/shard-recipes/scaffold.ts +187 -0
  62. package/src/shard-recipes/si-bare-village.ts +88 -0
  63. package/src/shard-recipes/street-affix.ts +549 -0
  64. package/src/shard-recipes/street-bare.ts +90 -0
  65. package/src/shard-recipes/street.ts +74 -0
  66. package/src/shard-recipes/unit.ts +315 -0
  67. package/src/split.ts +251 -0
  68. package/src/synthesize-anchor-absorption.ts +263 -0
  69. package/src/synthesize-boundary-stress.ts +504 -0
  70. package/src/synthesize-german.ts +227 -0
  71. package/src/synthesize-house-venue.ts +192 -0
  72. package/src/synthesize-intersection.ts +205 -0
  73. package/src/synthesize-no-street.ts +341 -0
  74. package/src/synthesize-po-box.ts +307 -0
  75. package/src/synthesize-street.ts +264 -0
  76. package/src/synthesize.ts +872 -0
  77. package/src/tokenize.ts +68 -0
  78. package/src/tools/align-shard.ts +67 -0
  79. package/src/tools/audit.ts +352 -0
  80. package/src/tools/corpus-stats.ts +183 -0
  81. package/src/tools/fetch/ban.ts +288 -0
  82. package/src/tools/fetch/download.ts +119 -0
  83. package/src/tools/fetch/hrsa.ts +65 -0
  84. package/src/tools/fetch/imls-pls.ts +162 -0
  85. package/src/tools/fetch/index.ts +121 -0
  86. package/src/tools/fetch/nad.ts +304 -0
  87. package/src/tools/fetch/nppes.ts +177 -0
  88. package/src/tools/fetch/openaddresses.ts +380 -0
  89. package/src/tools/fetch/state-hi-schools.ts +217 -0
  90. package/src/tools/fetch/state-sources.ts +142 -0
  91. package/src/tools/fetch/tiger-full.ts +318 -0
  92. package/src/tools/golden-expand.ts +524 -0
  93. package/src/tools/golden-promote.ts +304 -0
  94. package/src/tools/index.ts +24 -0
  95. package/src/tools/ingest-csv.ts +420 -0
  96. package/src/tools/jsonl-to-parquet.ts +200 -0
  97. package/src/tools/lint-shard-vocab.ts +357 -0
  98. package/src/tools/lint-shard.ts +502 -0
  99. package/src/tools/overlay-manifest.ts +141 -0
  100. package/src/tools/shard-kryptonite.ts +131 -0
  101. package/src/tools/shard-translit.ts +244 -0
  102. package/src/types.ts +213 -0
  103. package/src/wof-json.ts +236 -0
@@ -0,0 +1,160 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `state-tx-notaries`: Texas Notary Public Commissions CSV consumer.
7
+ *
8
+ * The Texas Secretary of State publishes a registry of commissioned notaries public. Each row
9
+ * optionally carries a mailing address in free-form text (often multi-line with embedded
10
+ * city/state/zip). Address fill rate is ~5-10%.
11
+ *
12
+ * The adapter parses the embedded `Address` field for city/state/zip using a trailing `"CITY, ST
13
+ * ZIP"` pattern.
14
+ *
15
+ * License: stamped `"Public Domain"` per Texas state government open-data terms.
16
+ */
17
+
18
+ import { createReadStream } from "node:fs"
19
+
20
+ import { parse as csvParse } from "csv-parse"
21
+
22
+ import { stableSourceID } from "../../adapter.ts"
23
+ import { lookupStateAbbreviation } from "../../codex/us-fips-state.ts"
24
+ import { reconcileComponents } from "../../format.ts"
25
+ import type { AdapterOptions, CanonicalRow, CorpusAdapter } from "../../types.ts"
26
+
27
+ export const STATE_TX_NOTARIES_ADAPTER_ID = "state-tx-notaries"
28
+ export const STATE_TX_NOTARIES_DEFAULT_LICENSE = "Public Domain"
29
+
30
+ const HOUSE_NUMBER_PREFIX = /^(\d+(?:-\d+)?[A-Za-z]?)\s+(.+)$/
31
+
32
+ /** Match trailing "CITY, ST ZIP" or "CITY, ST" at the end of an address line. */
33
+ const CITY_STATE_ZIP_SUFFIX = /[,]?\s*([^,]+),\s*([A-Z]{2})\s*(\d{5}(?:-\d{4})?)?\s*$/i
34
+
35
+ interface TxNotaryRow {
36
+ "Notary ID": string
37
+ "First Name": string
38
+ "Last Name": string
39
+ Address: string
40
+ }
41
+
42
+ function splitAddress(address: string): { house_number?: string; street: string } | null {
43
+ const trimmed = address.trim()
44
+
45
+ if (!trimmed) return null
46
+ const m = HOUSE_NUMBER_PREFIX.exec(trimmed)
47
+
48
+ if (m) return { house_number: m[1], street: m[2]!.trim() }
49
+
50
+ return { street: trimmed }
51
+ }
52
+
53
+ export function createStateTxNotariesAdapter(): CorpusAdapter {
54
+ return {
55
+ id: STATE_TX_NOTARIES_ADAPTER_ID,
56
+ defaultLicense: STATE_TX_NOTARIES_DEFAULT_LICENSE,
57
+ description:
58
+ "Texas Notary Public Commissions — name + mailing address with embedded city/state/zip (public-domain).",
59
+
60
+ async *rows(opts: AdapterOptions): AsyncIterable<CanonicalRow> {
61
+ if (opts.country && opts.country !== "US") {
62
+ throw new Error(`state-tx-notaries adapter: only US supported, got country=${opts.country}`)
63
+ }
64
+
65
+ const stream = createReadStream(opts.inputPath, { encoding: "utf8" })
66
+ const parser = stream.pipe(
67
+ csvParse({
68
+ columns: true,
69
+ skip_empty_lines: true,
70
+ relax_quotes: true,
71
+ relax_column_count: true,
72
+ })
73
+ )
74
+
75
+ let emitted = 0
76
+
77
+ try {
78
+ for await (const record of parser as AsyncIterable<TxNotaryRow>) {
79
+ if (opts.signal?.aborted) break
80
+
81
+ if (opts.limit !== undefined && emitted >= opts.limit) break
82
+
83
+ const rawAddress = (record.Address ?? "").trim()
84
+
85
+ if (!rawAddress) continue
86
+
87
+ const firstName = (record["First Name"] ?? "").trim()
88
+ const lastName = (record["Last Name"] ?? "").trim()
89
+ const notaryID = (record["Notary ID"] ?? "").trim()
90
+
91
+ // Parse embedded city/state/zip from the trailing portion of the address.
92
+ // Addresses look like: "1215 MCMILLAN DR\nCEDAR HILL, TX 75104"
93
+ const addrSingleLine = rawAddress.replace(/\n/g, ", ")
94
+ const cszMatch = CITY_STATE_ZIP_SUFFIX.exec(addrSingleLine)
95
+
96
+ if (!cszMatch) continue
97
+
98
+ const city = (cszMatch[1] ?? "").trim()
99
+ const stateAbbr = (cszMatch[2] ?? "").trim()
100
+ const zip = (cszMatch[3] ?? "").trim()
101
+
102
+ if (!city || !stateAbbr) continue
103
+
104
+ const state = lookupStateAbbreviation(stateAbbr)
105
+
106
+ if (!state) continue
107
+
108
+ // Extract the street portion (everything before the city/state/zip)
109
+ const streetPortion = addrSingleLine.slice(0, cszMatch.index).replace(/,\s*$/, "").trim()
110
+
111
+ if (!streetPortion) continue
112
+
113
+ const split = splitAddress(streetPortion)
114
+
115
+ if (!split) continue
116
+
117
+ const venue = [firstName, lastName].filter(Boolean).join(" ") || undefined
118
+
119
+ const components: CanonicalRow["components"] = {
120
+ ...(venue ? { venue } : {}),
121
+ ...(split.house_number ? { house_number: split.house_number } : {}),
122
+ street: split.street,
123
+ locality: city,
124
+ region: state.abbreviation,
125
+ ...(zip ? { postcode: zip } : {}),
126
+ }
127
+
128
+ const streetPart = [split.house_number, split.street].filter(Boolean).join(" ").trim()
129
+ const raw = [venue, streetPart, [city, [stateAbbr, zip].filter(Boolean).join(" ")].filter(Boolean).join(", ")]
130
+ .filter(Boolean)
131
+ .join(", ")
132
+
133
+ const aligned = reconcileComponents(components, raw)
134
+
135
+ if (Object.keys(aligned).length <= 2) continue
136
+
137
+ const sourceID = notaryID
138
+ ? `${STATE_TX_NOTARIES_ADAPTER_ID}-${notaryID}`
139
+ : stableSourceID(STATE_TX_NOTARIES_ADAPTER_ID, aligned)
140
+
141
+ yield {
142
+ raw,
143
+ components: aligned,
144
+ country: "US",
145
+ locale: "en-US",
146
+ source: STATE_TX_NOTARIES_ADAPTER_ID,
147
+ source_id: sourceID,
148
+ corpus_version: "",
149
+ license: STATE_TX_NOTARIES_DEFAULT_LICENSE,
150
+ }
151
+ emitted++
152
+ }
153
+ } finally {
154
+ stream.destroy()
155
+ }
156
+ },
157
+ }
158
+ }
159
+
160
+ export const stateTxNotariesAdapter = createStateTxNotariesAdapter()
@@ -0,0 +1,197 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `synth-po-box`: PO box / PMB / Apartado / BP synthesizer adapter.
7
+ *
8
+ * Consumes a JSONL stream of (locality, region, postcode, country) tuples — typically extracted
9
+ * from existing corpus output (TIGER/NAD/BAN/WOF) — and emits synthetic PO box training rows. See
10
+ * `../../synthesize-po-box.ts` for the per-locale templates and number-noise logic.
11
+ *
12
+ * Why an adapter and not an augmenter:
13
+ *
14
+ * - Per USPS Pub 28 / DMM 508, a PO box delivery line is mutually exclusive with a street line.
15
+ * Synthesizing PO boxes by mutating a street row would teach the model an invalid pattern.
16
+ * The clean shape is: read just (locality, region, postcode, country) and produce a fresh
17
+ * PO-box-shaped row.
18
+ * - Per-DeepSeek (3-turn consult, 2026-05-28): PMB rows that COMBINE a street line with a PMB number
19
+ * ARE valid (CMRA addresses). Those are produced when `pmbRatio > 0` AND the input tuple
20
+ * carries a `street` field.
21
+ */
22
+
23
+ import { TextSpliterator } from "spliterator"
24
+
25
+ import { stableSourceID } from "../../adapter.ts"
26
+ import {
27
+ countryToLocale,
28
+ REGION_OPTIONAL_LOCALES,
29
+ synthesizeMilitaryPoBoxRow,
30
+ synthesizePoBoxRow,
31
+ type PoBoxBaseTuple,
32
+ } from "../../synthesize-po-box.ts"
33
+ import type { AdapterOptions, CanonicalRow, CorpusAdapter } from "../../types.ts"
34
+
35
+ export const SYNTH_PO_BOX_ADAPTER_ID = "synth-po-box"
36
+ export const SYNTH_PO_BOX_LICENSE = "Synthetic — derived from CC-BY / public-domain input tuples"
37
+
38
+ export interface PoBoxInputRow extends PoBoxBaseTuple {
39
+ street?: string
40
+ houseNumber?: string
41
+ }
42
+
43
+ export interface SynthPoBoxAdapterOptions {
44
+ /**
45
+ * How many PO box variants to emit per input tuple. Each variant picks a different leader (and possibly a different
46
+ * number / noise level). Default 1.
47
+ */
48
+ variantsPerInput?: number
49
+ /**
50
+ * Probability (0..1) of emitting a PMB-with-street variant when both the input has a street and the locale supports
51
+ * PMB. Default 0.15.
52
+ */
53
+ pmbRatio?: number
54
+ /**
55
+ * Deterministic seed for reproducible synthesis. Default Date.now().
56
+ */
57
+ seed?: number
58
+ /**
59
+ * Probability (0..1), evaluated per input tuple, of ALSO emitting one US military/diplomatic PO-box row
60
+ * (`PSC/CMR/Unit <id> Box <box>, APO/FPO/DPO AA/AE/AP <zip>`, #517). These rows are self-contained — they draw no
61
+ * field from the input tuple, so military volume scales with the input stream size. Default 0 (off) — the adapter's
62
+ * contract is "one row per input"; the corpus build recipe opts in to seed the rare-but-real military class without
63
+ * changing the default.
64
+ */
65
+ militaryRatio?: number
66
+ }
67
+
68
+ function makeRandom(seed: number): () => number {
69
+ let s = seed
70
+
71
+ return () => {
72
+ s = (s * 1664525 + 1013904223) % 4294967296
73
+
74
+ return s / 4294967296
75
+ }
76
+ }
77
+
78
+ export function createSynthPoBoxAdapter(opts: SynthPoBoxAdapterOptions = {}): CorpusAdapter {
79
+ const variantsPerInput = opts.variantsPerInput ?? 1
80
+ const pmbRatio = opts.pmbRatio ?? 0.15
81
+ const militaryRatio = opts.militaryRatio ?? 0
82
+
83
+ return {
84
+ id: SYNTH_PO_BOX_ADAPTER_ID,
85
+ defaultLicense: SYNTH_PO_BOX_LICENSE,
86
+ description:
87
+ "Synthetic PO box / PMB / Apartado / Boîte Postale rows. Consumes JSONL of (locality, region, postcode, country) tuples and emits locale-appropriate PO box variants.",
88
+
89
+ async *rows(options: AdapterOptions): AsyncIterable<CanonicalRow> {
90
+ const random = makeRandom(opts.seed ?? Date.now())
91
+
92
+ // TextSpliterator streams string lines; the per-line try/catch below keeps this reader
93
+ // tolerant of malformed rows (skipped++), so TextSpliterator + explicit JSON.parse — not
94
+ // JSONSpliterator, which would throw on the first bad line.
95
+ const lines = TextSpliterator.fromAsync(options.inputPath)
96
+
97
+ let emitted = 0
98
+ let skipped = 0
99
+ let militarySeq = 0
100
+
101
+ for await (const line of lines) {
102
+ if (options.signal?.aborted) break
103
+
104
+ if (options.limit !== undefined && emitted >= options.limit) break
105
+
106
+ const trimmed = line.trim()
107
+
108
+ if (!trimmed) continue
109
+
110
+ let input: PoBoxInputRow
111
+
112
+ try {
113
+ input = JSON.parse(trimmed) as PoBoxInputRow
114
+ } catch {
115
+ skipped++
116
+ continue
117
+ }
118
+
119
+ // Region is required EXCEPT for region-less locales (NZ: `Private Bag 12, Auckland 1010`
120
+ // has no region token, #517). synthesizePoBoxRow handles region absence; the guard just
121
+ // must not discard those tuples as "missing region".
122
+ const regionOptional = input.country ? REGION_OPTIONAL_LOCALES.has(countryToLocale(input.country)) : false
123
+
124
+ if (!input.locality || !input.postcode || !input.country || (!input.region && !regionOptional)) {
125
+ skipped++
126
+ continue
127
+ }
128
+
129
+ if (options.country && options.country !== input.country) continue
130
+
131
+ for (let v = 0; v < variantsPerInput; v++) {
132
+ const synth = synthesizePoBoxRow(input, { random, pmbRatio })
133
+
134
+ if (!synth) continue
135
+
136
+ // Include `v` in dependent_locality slot to vary the digest across variants;
137
+ // stableSourceID only accepts ComponentTag keys.
138
+ const sourceID = stableSourceID(SYNTH_PO_BOX_ADAPTER_ID, {
139
+ locality: `${input.locality}#${v}`,
140
+ region: input.region,
141
+ postcode: input.postcode,
142
+ country: input.country,
143
+ })
144
+
145
+ yield {
146
+ raw: synth.raw,
147
+ components: synth.components,
148
+ country: input.country,
149
+ locale: synth.locale,
150
+ source: SYNTH_PO_BOX_ADAPTER_ID,
151
+ source_id: sourceID,
152
+ corpus_version: "",
153
+ license: SYNTH_PO_BOX_LICENSE,
154
+ }
155
+ emitted++
156
+
157
+ if (options.limit !== undefined && emitted >= options.limit) break
158
+ }
159
+
160
+ // US military/diplomatic PO-box rows (#517): self-contained — draw nothing from the input
161
+ // tuple — emitted per input line with probability `militaryRatio` (off by default, so the
162
+ // default random stream and output are byte-identical). Military volume scales with the
163
+ // stream rather than the US-tuple count. US-only: suppressed under a non-US country filter
164
+ // and counted against `limit` like any other row.
165
+ const militaryAllowed = !options.country || options.country === "US"
166
+
167
+ if (
168
+ militaryRatio > 0 &&
169
+ militaryAllowed &&
170
+ (options.limit === undefined || emitted < options.limit) &&
171
+ random() < militaryRatio
172
+ ) {
173
+ const mil = synthesizeMilitaryPoBoxRow({ random })
174
+ const sourceID = stableSourceID(SYNTH_PO_BOX_ADAPTER_ID, {
175
+ po_box: `${mil.components.po_box}#mil${militarySeq++}`,
176
+ locality: mil.components.locality!,
177
+ region: mil.components.region!,
178
+ postcode: mil.components.postcode!,
179
+ })
180
+ yield {
181
+ raw: mil.raw,
182
+ components: mil.components,
183
+ country: "US",
184
+ locale: mil.locale,
185
+ source: SYNTH_PO_BOX_ADAPTER_ID,
186
+ source_id: sourceID,
187
+ corpus_version: "",
188
+ license: SYNTH_PO_BOX_LICENSE,
189
+ }
190
+ emitted++
191
+ }
192
+ }
193
+ },
194
+ }
195
+ }
196
+
197
+ export const synthPoBoxAdapter = createSynthPoBoxAdapter()
@@ -0,0 +1,232 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `tiger`: US Census TIGER/Line consumer adapter.
7
+ *
8
+ * TIGER/Line is the canonical US street + locality dataset published by the Census Bureau as a
9
+ * **public-domain** product (no ODbL share-alike concerns for US-only corpora). Coverage extends
10
+ * to every named street segment + every incorporated place + CDP across the 50 states + DC + the
11
+ * five primary territories — substantially better US street-name coverage than OSM, especially in
12
+ * rural areas.
13
+ *
14
+ * Following the `wof-admin` / `wof-postalcode` pattern, this adapter consumes a SQLite database the
15
+ * operator pre-builds from the raw TIGER shapefiles (see the README for the schema and a
16
+ * suggested `ogr2ogr` pipeline). The mailwoman side does not parse Shapefile binary directly —
17
+ * keeping the adapter narrow lets the operator pick their own ingestion tool (ogr2ogr / shp2pgsql
18
+ * / a custom Python script / etc.) without forcing a heavy native dep into `@mailwoman/corpus`.
19
+ *
20
+ * Two row classes are emitted:
21
+ *
22
+ * - **Street-level** (`tiger_streets`): one row per segment, optionally with up to two postcode
23
+ * variants if `zipl` / `zipr` differ. Components: `{ street, region, postcode? }`. Streets
24
+ * without a recognized state FIPS are dropped — there's no useful row without `region`.
25
+ * - **Locality-level** (`tiger_places`): up to three variants per place: locality-only,
26
+ * locality-with-region, locality-with-region-country (mirrors `wof-admin`'s fan-out for
27
+ * consistency).
28
+ * - `packages/corpus/src/codex/us-fips-state.ts` — the FIPS → `{abbreviation, name}` lookup table
29
+ * (originally `tiger/state.ts`, AGPL-3.0 → AGPL-3.0). The full isp-nexus TIGER module ships a
30
+ * TypeORM-backed service layer; mailwoman only needs the lookup data so we don't carry the
31
+ * service layer over.
32
+ *
33
+ * License: stamped `"Public Domain"` per Census Bureau guidance on TIGER/Line. No per-row override
34
+ * needed — every row in TIGER is the same license.
35
+ */
36
+
37
+ import { DatabaseSync } from "node:sqlite"
38
+
39
+ import { lookupFipsState } from "../../codex/us-fips-state.ts"
40
+ import { formatAddress, reconcileComponents } from "../../format.ts"
41
+ import type { AdapterOptions, CanonicalRow, CorpusAdapter } from "../../types.ts"
42
+ import { decomposeStreet } from "./street-decompose.ts"
43
+
44
+ export const TIGER_ADAPTER_ID = "tiger"
45
+ export const TIGER_DEFAULT_LICENSE = "Public Domain"
46
+
47
+ /**
48
+ * The country surface form used in `formatAddress` for US. Matches the canonical OpenCage US template output so
49
+ * reconciliation doesn't strip it when the row carries `country` explicitly.
50
+ */
51
+ const US_COUNTRY_DISPLAY = "United States of America"
52
+
53
+ interface TigerStreetRow {
54
+ linearid: string
55
+ fullname: string
56
+ zipl: string | null
57
+ zipr: string | null
58
+ statefp: string
59
+ }
60
+
61
+ interface TigerPlaceRow {
62
+ geoid: string
63
+ name: string
64
+ statefp: string
65
+ lsad: string | null
66
+ }
67
+
68
+ /**
69
+ * Yield one or more `CanonicalRow`s per street segment. Postcode variants:
70
+ *
71
+ * - No ZIP set → one row, street + region.
72
+ * - `zipl === zipr` → one row, street + region + postcode.
73
+ * - `zipl !== zipr` → two rows (one per side's ZIP).
74
+ */
75
+ function* streetVariants(row: TigerStreetRow): Iterable<{
76
+ components: CanonicalRow["components"]
77
+ variantKey: string
78
+ }> {
79
+ const fullname = row.fullname.trim()
80
+
81
+ if (!fullname) return
82
+ const state = lookupFipsState(row.statefp)
83
+
84
+ if (!state) return
85
+
86
+ const zipl = row.zipl?.trim() ?? ""
87
+ const zipr = row.zipr?.trim() ?? ""
88
+
89
+ const decomposed = decomposeStreet(fullname)
90
+
91
+ const baseComponents: CanonicalRow["components"] = {
92
+ region: state.abbreviation,
93
+ street: decomposed.street,
94
+ }
95
+
96
+ if (decomposed.prefix) {
97
+ baseComponents.street_prefix = decomposed.prefix
98
+ }
99
+
100
+ if (decomposed.suffix) {
101
+ baseComponents.street_suffix = decomposed.suffix
102
+ }
103
+
104
+ if (!zipl && !zipr) {
105
+ yield { components: baseComponents, variantKey: "no-zip" }
106
+
107
+ return
108
+ }
109
+
110
+ if (zipl && zipr && zipl === zipr) {
111
+ yield {
112
+ components: { ...baseComponents, postcode: zipl },
113
+ variantKey: `zip-${zipl}`,
114
+ }
115
+
116
+ return
117
+ }
118
+
119
+ if (zipl) {
120
+ yield { components: { ...baseComponents, postcode: zipl }, variantKey: `zipl-${zipl}` }
121
+ }
122
+
123
+ if (zipr && zipr !== zipl) {
124
+ yield { components: { ...baseComponents, postcode: zipr }, variantKey: `zipr-${zipr}` }
125
+ }
126
+ }
127
+
128
+ /** Three locality-level variants, mirroring `wof-admin`'s fan-out. */
129
+ function* placeVariants(row: TigerPlaceRow): Iterable<{
130
+ components: CanonicalRow["components"]
131
+ variantKey: string
132
+ }> {
133
+ const name = row.name.trim()
134
+
135
+ if (!name) return
136
+ const state = lookupFipsState(row.statefp)
137
+
138
+ if (!state) return
139
+
140
+ yield {
141
+ components: { locality: name },
142
+ variantKey: "locality-only",
143
+ }
144
+ yield {
145
+ components: { locality: name, region: state.abbreviation },
146
+ variantKey: "with-region",
147
+ }
148
+ yield {
149
+ components: { locality: name, region: state.abbreviation, country: US_COUNTRY_DISPLAY },
150
+ variantKey: "with-region-country",
151
+ }
152
+ }
153
+
154
+ /** Build a TIGER adapter. Pure factory so multiple instances can be created in tests. */
155
+ export function createTigerAdapter(): CorpusAdapter {
156
+ return {
157
+ id: TIGER_ADAPTER_ID,
158
+ defaultLicense: TIGER_DEFAULT_LICENSE,
159
+ description:
160
+ "US Census TIGER/Line streets + places consumer (public-domain); SQLite DB built via `mailwoman tiger fetch`.",
161
+
162
+ async *rows(opts: AdapterOptions): AsyncIterable<CanonicalRow> {
163
+ if (opts.country && opts.country !== "US") {
164
+ throw new Error(`tiger adapter: only US supported, got country=${opts.country}`)
165
+ }
166
+
167
+ const db = new DatabaseSync(opts.inputPath, { readOnly: true })
168
+ let emitted = 0
169
+
170
+ try {
171
+ const streetStmt = db.prepare(`SELECT linearid, fullname, zipl, zipr, statefp FROM tiger_streets`)
172
+ const placeStmt = db.prepare(`SELECT geoid, name, statefp, lsad FROM tiger_places`)
173
+
174
+ for (const row of streetStmt.iterate() as IterableIterator<TigerStreetRow>) {
175
+ if (opts.signal?.aborted) return
176
+
177
+ for (const variant of streetVariants(row)) {
178
+ if (opts.limit !== undefined && emitted >= opts.limit) return
179
+ const raw = formatAddress(variant.components, "US", { separator: ", " })
180
+
181
+ if (!raw) continue
182
+ const aligned = reconcileComponents(variant.components, raw)
183
+
184
+ if (Object.keys(aligned).length === 0) continue
185
+
186
+ yield {
187
+ raw,
188
+ components: aligned,
189
+ country: "US",
190
+ locale: "en-US",
191
+ source: TIGER_ADAPTER_ID,
192
+ source_id: `${TIGER_ADAPTER_ID}-st-${row.linearid}-${variant.variantKey}`,
193
+ corpus_version: "",
194
+ license: TIGER_DEFAULT_LICENSE,
195
+ }
196
+ emitted++
197
+ }
198
+ }
199
+
200
+ for (const row of placeStmt.iterate() as IterableIterator<TigerPlaceRow>) {
201
+ if (opts.signal?.aborted) return
202
+
203
+ for (const variant of placeVariants(row)) {
204
+ if (opts.limit !== undefined && emitted >= opts.limit) return
205
+ const raw = formatAddress(variant.components, "US", { separator: ", " })
206
+
207
+ if (!raw) continue
208
+ const aligned = reconcileComponents(variant.components, raw)
209
+
210
+ if (Object.keys(aligned).length === 0) continue
211
+
212
+ yield {
213
+ raw,
214
+ components: aligned,
215
+ country: "US",
216
+ locale: "en-US",
217
+ source: TIGER_ADAPTER_ID,
218
+ source_id: `${TIGER_ADAPTER_ID}-pl-${row.geoid}-${variant.variantKey}`,
219
+ corpus_version: "",
220
+ license: TIGER_DEFAULT_LICENSE,
221
+ }
222
+ emitted++
223
+ }
224
+ }
225
+ } finally {
226
+ db.close()
227
+ }
228
+ },
229
+ }
230
+ }
231
+
232
+ export const tigerAdapter = createTigerAdapter()