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