@mailwoman/corpus 7.2.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 +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,120 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Decompose a US street name into Stage 3 components: street_prefix, street, street_suffix.
7
+ *
8
+ * Sources directionals and street types from the curated libpostal/en dictionaries
9
+ * (`core/data/libpostal/dictionaries/en/{directionals,street_types}.txt`). These are the same
10
+ * dictionaries the runtime classifiers (StreetPrefixClassifier, StreetSuffixClassifier) use, so
11
+ * corpus labels and runtime classifications agree on the vocabulary.
12
+ *
13
+ * Examples: "N Main St" → { prefix: "N", street: "Main", suffix: "St" } "Pennsylvania Avenue NW" →
14
+ * { prefix: null, street: "Pennsylvania", suffix: "Avenue NW" } "Salmon St" → { prefix: null,
15
+ * street: "Salmon", suffix: "St" } "SE Hawthorne Blvd" → { prefix: "SE", street: "Hawthorne",
16
+ * suffix: "Blvd" }
17
+ */
18
+
19
+ import { readFileSync } from "node:fs"
20
+ import { resolve } from "node:path"
21
+
22
+ import { resourceDictionaryPath } from "@mailwoman/core/utils"
23
+
24
+ function loadDictionary(filename: string): Set<string> {
25
+ // Resolve via the @mailwoman/core data directory.
26
+ const candidates = [
27
+ resourceDictionaryPath("libpostal", "en", filename),
28
+ resourceDictionaryPath("libpostal", "en", filename),
29
+ resolve(process.cwd(), "core/data/libpostal/dictionaries/en", filename),
30
+ ]
31
+
32
+ for (const path of candidates) {
33
+ try {
34
+ const text = readFileSync(path, "utf8")
35
+ const set = new Set<string>()
36
+
37
+ for (const line of text.split("\n")) {
38
+ const trimmed = line.trim()
39
+
40
+ if (!trimmed || trimmed.startsWith("#")) continue
41
+
42
+ // libpostal format: canonical|abbr|abbr|... — index all forms
43
+ for (const form of trimmed.split("|")) {
44
+ const f = form.trim().toLowerCase()
45
+
46
+ if (f) {
47
+ set.add(f)
48
+ }
49
+ }
50
+ }
51
+
52
+ return set
53
+ } catch {
54
+ // try next candidate
55
+ }
56
+ }
57
+ throw new Error(`Could not load libpostal dictionary: ${filename}`)
58
+ }
59
+
60
+ const DIRECTIONALS = loadDictionary("directionals.txt")
61
+ const STREET_TYPES = loadDictionary("street_types.txt")
62
+
63
+ export interface DecomposedStreet {
64
+ prefix: string | null
65
+ street: string
66
+ suffix: string | null
67
+ }
68
+
69
+ /**
70
+ * Decompose a US street name into prefix/name/suffix components.
71
+ *
72
+ * Conservative — only emits prefix/suffix when there's a clear directional or street-type keyword. Returns the original
73
+ * as `street` if nothing matches.
74
+ */
75
+ export function decomposeStreet(fullname: string): DecomposedStreet {
76
+ const trimmed = fullname.trim()
77
+
78
+ if (!trimmed) return { prefix: null, street: "", suffix: null }
79
+
80
+ const tokens = trimmed.split(/\s+/)
81
+
82
+ if (tokens.length === 1) return { prefix: null, street: trimmed, suffix: null }
83
+
84
+ const norm = (s: string) => s.toLowerCase().replace(/\.$/, "")
85
+
86
+ let prefix: string | null = null
87
+ let suffix: string | null = null
88
+ let startIdx = 0
89
+ let endIdx = tokens.length
90
+
91
+ // Leading directional prefix
92
+ if (DIRECTIONALS.has(norm(tokens[0]!)) && tokens.length >= 2) {
93
+ prefix = tokens[0]!
94
+ startIdx = 1
95
+ }
96
+
97
+ // Trailing post-directional combined with street type (e.g. "Pennsylvania Ave NW")
98
+ const last = norm(tokens[endIdx - 1]!)
99
+ const secondLast = endIdx >= 2 ? norm(tokens[endIdx - 2]!) : ""
100
+
101
+ if (DIRECTIONALS.has(last) && STREET_TYPES.has(secondLast)) {
102
+ suffix = tokens.slice(endIdx - 2, endIdx).join(" ")
103
+ endIdx -= 2
104
+ } else if (STREET_TYPES.has(last) && endIdx - startIdx >= 2) {
105
+ suffix = tokens[endIdx - 1]!
106
+ endIdx -= 1
107
+ } else if (DIRECTIONALS.has(last) && endIdx - startIdx >= 2) {
108
+ // Post-directional without type
109
+ suffix = tokens[endIdx - 1]!
110
+ endIdx -= 1
111
+ }
112
+
113
+ const street = tokens.slice(startIdx, endIdx).join(" ").trim()
114
+
115
+ if (!street) {
116
+ return { prefix: null, street: trimmed, suffix: null }
117
+ }
118
+
119
+ return { prefix, street, suffix }
120
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `usgov-hrsa-fqhc`: HRSA "Health Center Service Delivery Site Locations" CSV consumer.
7
+ *
8
+ * Federally Qualified Health Centers (FQHCs) are HRSA-funded community health programs that
9
+ * self-report site addresses to the HRSA Data Warehouse. The published CSV (`data.hrsa.gov`)
10
+ * carries the site name, the postal-formatted street address, and the locality/region/postcode
11
+ * quad. Phase 1.6 §1.2 (#22) selects this source for its adversarial-value-per-row: every
12
+ * facility name is a human-typed venue string and the addresses pass through enough hands to
13
+ * accumulate the abbreviation drift + suite designator chaos that pure gazetteer data does not.
14
+ *
15
+ * The adapter consumes a CSV file the operator pre-downloads. The HRSA data is published as a
16
+ * single national CSV (~10K rows), small enough that the operator can re-fetch on every corpus
17
+ * rebuild without an intermediate SQLite step. Column names below match the HRSA Data Warehouse's
18
+ * "Health Center Service Delivery Site" public dataset. Operators substituting the
19
+ * closely-related "Site Address" or "Health Center" public extracts may need to remap columns;
20
+ * the README documents the expected set.
21
+ *
22
+ * Output: one row per CSV record, with `venue` component carrying the site name and the address
23
+ * quad on `(house_number, street, locality, region, postcode)`. Component order is critical:
24
+ * `venue` is inserted FIRST so alignment claims its surface span before `locality` searches for
25
+ * its own (the kryptonite case "Buffalo Health Clinic, …, Buffalo, NY" relies on `venue`
26
+ * consuming the first "Buffalo" so locality lands on the second).
27
+ *
28
+ * License: stamped `"Public Domain"` per the HRSA Data Warehouse's federal government distribution
29
+ * terms.
30
+ */
31
+
32
+ import { createReadStream } from "node:fs"
33
+
34
+ import { parse as csvParse } from "csv-parse"
35
+
36
+ import { stableSourceID } from "../../adapter.ts"
37
+ import { lookupStateAbbreviation } from "../../codex/us-fips-state.ts"
38
+ import { reconcileComponents } from "../../format.ts"
39
+ import type { AdapterOptions, CanonicalRow, CorpusAdapter } from "../../types.ts"
40
+
41
+ export const USGOV_HRSA_FQHC_ADAPTER_ID = "usgov-hrsa-fqhc"
42
+ export const USGOV_HRSA_FQHC_DEFAULT_LICENSE = "Public Domain"
43
+
44
+ /**
45
+ * Subset of HRSA "Health Center Service Delivery Site Locations" CSV columns consulted by the adapter. Column names
46
+ * match the canonical HRSA Data Warehouse export header. Operators substituting a closely-related extract should rename
47
+ * columns to match; the README has the mapping cheatsheet.
48
+ */
49
+ interface HrsaSiteRow {
50
+ "Site Name": string
51
+ "Site Address": string
52
+ "Site City": string
53
+ "Site State Abbreviation": string
54
+ "Site Postal Code": string
55
+ /** Optional. Falls back to `stableSourceID` derived from components when missing. */
56
+ "Site ID"?: string
57
+ }
58
+
59
+ /**
60
+ * Split a "123 Main St Suite 4" surface form into `(house_number, street)`. The regex tolerates one trailing letter on
61
+ * the number (`"123A Main St"`) and a hyphenated form (`"40-12 Bell Blvd"`); anything else falls back to street-only.
62
+ *
63
+ * Suite / Apt / Unit designators stay on `street` for Phase 1 — Mailwoman's `unit` component exists but the
64
+ * address-formatter does not have a clean slot for it, and HRSA addresses do not separate the suite into its own
65
+ * column. Leaving the surface form intact in `street` preserves the adversarial training signal (the model learns that
66
+ * a trailing "Suite 4" is part of the road line in this distribution).
67
+ */
68
+ const HOUSE_NUMBER_PREFIX = /^(\d+(?:-\d+)?[A-Za-z]?)\s+(.+)$/
69
+
70
+ function splitAddress(address: string): { house_number?: string; street: string } | null {
71
+ const trimmed = address.trim()
72
+
73
+ if (!trimmed) return null
74
+ const m = HOUSE_NUMBER_PREFIX.exec(trimmed)
75
+
76
+ if (m) return { house_number: m[1], street: m[2]!.trim() }
77
+
78
+ return { street: trimmed }
79
+ }
80
+
81
+ /**
82
+ * Compose the raw envelope-style address line. Format:
83
+ *
84
+ * "<Site Name>, <house> <street>, <city>, <state> <postcode>"
85
+ *
86
+ * The site name leads (US conventional addressee-then-address ordering) so a downstream model sees the
87
+ * venue-prefix-then-address shape that HRSA users actually type into geocoders.
88
+ */
89
+ function composeRaw(
90
+ venue: string,
91
+ house: string | undefined,
92
+ street: string,
93
+ city: string,
94
+ state: string,
95
+ postcode: string
96
+ ): string {
97
+ const streetPart = [house, street].filter(Boolean).join(" ").trim()
98
+ const cityPart = [city.trim(), [state, postcode].filter(Boolean).join(" ").trim()].filter(Boolean).join(", ")
99
+
100
+ return [venue.trim(), streetPart, cityPart].filter(Boolean).join(", ")
101
+ }
102
+
103
+ export function createUsgovHrsaFqhcAdapter(): CorpusAdapter {
104
+ return {
105
+ id: USGOV_HRSA_FQHC_ADAPTER_ID,
106
+ defaultLicense: USGOV_HRSA_FQHC_DEFAULT_LICENSE,
107
+ description:
108
+ "HRSA Federally Qualified Health Center site locations (public-domain). Adversarial source: venue + address co-occurrence, hand-entered.",
109
+
110
+ async *rows(opts: AdapterOptions): AsyncIterable<CanonicalRow> {
111
+ if (opts.country && opts.country !== "US") {
112
+ throw new Error(`usgov-hrsa-fqhc adapter: only US supported, got country=${opts.country}`)
113
+ }
114
+
115
+ const stream = createReadStream(opts.inputPath, { encoding: "utf8" })
116
+ const parser = stream.pipe(
117
+ csvParse({
118
+ columns: true,
119
+ skip_empty_lines: true,
120
+ relax_quotes: true,
121
+ relax_column_count: true,
122
+ })
123
+ )
124
+
125
+ let emitted = 0
126
+
127
+ try {
128
+ for await (const record of parser as AsyncIterable<HrsaSiteRow>) {
129
+ if (opts.signal?.aborted) break
130
+
131
+ if (opts.limit !== undefined && emitted >= opts.limit) break
132
+
133
+ const venue = (record["Site Name"] ?? "").trim()
134
+ const split = splitAddress(record["Site Address"] ?? "")
135
+ const city = (record["Site City"] ?? "").trim()
136
+ const stateAbbr = (record["Site State Abbreviation"] ?? "").trim()
137
+ const postcode = (record["Site Postal Code"] ?? "").trim()
138
+
139
+ if (!venue || !split || !city || !postcode) continue
140
+ const state = lookupStateAbbreviation(stateAbbr)
141
+
142
+ if (!state) continue
143
+
144
+ // Insertion order matters here. `venue` first so alignment claims its span
145
+ // (which may contain a token like "Buffalo") before `locality` runs its
146
+ // search — the kryptonite case `Buffalo Health Clinic, Buffalo NY`
147
+ // otherwise mis-labels the venue's "Buffalo" as locality.
148
+ const components: CanonicalRow["components"] = {
149
+ venue,
150
+ ...(split.house_number ? { house_number: split.house_number } : {}),
151
+ street: split.street,
152
+ locality: city,
153
+ region: state.abbreviation,
154
+ postcode,
155
+ }
156
+
157
+ const raw = composeRaw(venue, split.house_number, split.street, city, state.abbreviation, postcode)
158
+
159
+ if (!raw) continue
160
+
161
+ const aligned = reconcileComponents(components, raw)
162
+
163
+ if (Object.keys(aligned).length === 0) continue
164
+
165
+ const siteID = (record["Site ID"] ?? "").trim()
166
+ const sourceID = siteID
167
+ ? `${USGOV_HRSA_FQHC_ADAPTER_ID}-${siteID}`
168
+ : stableSourceID(USGOV_HRSA_FQHC_ADAPTER_ID, aligned)
169
+
170
+ yield {
171
+ raw,
172
+ components: aligned,
173
+ country: "US",
174
+ locale: "en-US",
175
+ source: USGOV_HRSA_FQHC_ADAPTER_ID,
176
+ source_id: sourceID,
177
+ corpus_version: "",
178
+ license: USGOV_HRSA_FQHC_DEFAULT_LICENSE,
179
+ }
180
+ emitted++
181
+ }
182
+ } finally {
183
+ stream.destroy()
184
+ }
185
+ },
186
+ }
187
+ }
188
+
189
+ export const usgovHrsaFqhcAdapter = createUsgovHrsaFqhcAdapter()
@@ -0,0 +1,152 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `usgov-imls-pls`: IMLS Public Libraries Survey outlet CSV consumer.
7
+ *
8
+ * The Institute of Museum and Library Services publishes an annual Public Libraries Survey with one
9
+ * row per library outlet (~17K rows). Each row carries the library name, street address, city,
10
+ * ZIP, county, and geocoordinates.
11
+ *
12
+ * The adapter consumes the outlet CSV the operator pre-downloads via `fetch-imls-pls.ts`. Column
13
+ * names match the IMLS PLS outlet file header.
14
+ *
15
+ * Output: one row per outlet with `venue` (library name), `(house_number, street, locality,
16
+ * subregion, postcode)`, and lat/lon preserved in `source_id` stability.
17
+ *
18
+ * License: stamped `"Public Domain"` per IMLS federal government distribution terms.
19
+ */
20
+
21
+ import { createReadStream } from "node:fs"
22
+
23
+ import { parse as csvParse } from "csv-parse"
24
+
25
+ import { stableSourceID } from "../../adapter.ts"
26
+ import { lookupStateAbbreviation } from "../../codex/us-fips-state.ts"
27
+ import { reconcileComponents } from "../../format.ts"
28
+ import type { AdapterOptions, CanonicalRow, CorpusAdapter } from "../../types.ts"
29
+
30
+ export const USGOV_IMLS_PLS_ADAPTER_ID = "usgov-imls-pls"
31
+ export const USGOV_IMLS_PLS_DEFAULT_LICENSE = "Public Domain"
32
+
33
+ const HOUSE_NUMBER_PREFIX = /^(\d+(?:-\d+)?[A-Za-z]?)\s+(.+)$/
34
+
35
+ interface ImlsOutletRow {
36
+ LIBNAME: string
37
+ ADDRESS: string
38
+ CITY: string
39
+ ZIP: string
40
+ STABR: string
41
+ CNTY: string
42
+ FSCSKEY: string
43
+ }
44
+
45
+ function splitAddress(address: string): { house_number?: string; street: string } | null {
46
+ const trimmed = address.trim()
47
+
48
+ if (!trimmed) return null
49
+ const m = HOUSE_NUMBER_PREFIX.exec(trimmed)
50
+
51
+ if (m) return { house_number: m[1], street: m[2]!.trim() }
52
+
53
+ return { street: trimmed }
54
+ }
55
+
56
+ export function createUsgovImlsPlsAdapter(): CorpusAdapter {
57
+ return {
58
+ id: USGOV_IMLS_PLS_ADAPTER_ID,
59
+ defaultLicense: USGOV_IMLS_PLS_DEFAULT_LICENSE,
60
+ description: "IMLS Public Libraries Survey — ~17K library outlets with venue+address (public-domain).",
61
+
62
+ async *rows(opts: AdapterOptions): AsyncIterable<CanonicalRow> {
63
+ if (opts.country && opts.country !== "US") {
64
+ throw new Error(`usgov-imls-pls adapter: only US supported, got country=${opts.country}`)
65
+ }
66
+
67
+ const stream = createReadStream(opts.inputPath, { encoding: "utf8" })
68
+ const parser = stream.pipe(
69
+ csvParse({
70
+ columns: true,
71
+ skip_empty_lines: true,
72
+ relax_quotes: true,
73
+ relax_column_count: true,
74
+ })
75
+ )
76
+
77
+ let emitted = 0
78
+
79
+ try {
80
+ for await (const record of parser as AsyncIterable<ImlsOutletRow>) {
81
+ if (opts.signal?.aborted) break
82
+
83
+ if (opts.limit !== undefined && emitted >= opts.limit) break
84
+
85
+ const libName = (record.LIBNAME ?? "").trim()
86
+ const address = (record.ADDRESS ?? "").trim()
87
+ const city = (record.CITY ?? "").trim()
88
+ const zip = (record.ZIP ?? "").trim()
89
+ const stateAbbr = (record.STABR ?? "").trim()
90
+ const county = (record.CNTY ?? "").trim()
91
+
92
+ if (!libName || !city || !zip) continue
93
+
94
+ const state = lookupStateAbbreviation(stateAbbr)
95
+
96
+ if (!state) continue
97
+
98
+ const split = splitAddress(address)
99
+
100
+ if (!split) continue
101
+
102
+ const components: CanonicalRow["components"] = {
103
+ venue: libName,
104
+ ...(split.house_number ? { house_number: split.house_number } : {}),
105
+ street: split.street,
106
+ locality: city,
107
+ region: state.abbreviation,
108
+ postcode: zip,
109
+ // #552: no subregion — US postal addresses don't surface the county, so emitting
110
+ // subregion creates a phantom component with no raw-span to align to, quarantining
111
+ // ~21% of rows. The county is still available in the source CSV; it just isn't
112
+ // a postal-surface component here.
113
+ }
114
+
115
+ const streetPart = [split.house_number, split.street].filter(Boolean).join(" ").trim()
116
+ const raw = [
117
+ libName,
118
+ streetPart,
119
+ [city, [stateAbbr, zip].filter(Boolean).join(" ")].filter(Boolean).join(", "),
120
+ ]
121
+ .filter(Boolean)
122
+ .join(", ")
123
+
124
+ const aligned = reconcileComponents(components, raw)
125
+
126
+ if (Object.keys(aligned).length <= 2) continue
127
+
128
+ const fscsKey = (record.FSCSKEY ?? "").trim()
129
+ const sourceID = fscsKey
130
+ ? `${USGOV_IMLS_PLS_ADAPTER_ID}-${fscsKey}`
131
+ : stableSourceID(USGOV_IMLS_PLS_ADAPTER_ID, aligned)
132
+
133
+ yield {
134
+ raw,
135
+ components: aligned,
136
+ country: "US",
137
+ locale: "en-US",
138
+ source: USGOV_IMLS_PLS_ADAPTER_ID,
139
+ source_id: sourceID,
140
+ corpus_version: "",
141
+ license: USGOV_IMLS_PLS_DEFAULT_LICENSE,
142
+ }
143
+ emitted++
144
+ }
145
+ } finally {
146
+ stream.destroy()
147
+ }
148
+ },
149
+ }
150
+ }
151
+
152
+ export const usgovImlsPlsAdapter = createUsgovImlsPlsAdapter()
@@ -0,0 +1,155 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `usgov-irs-bmf`: IRS Exempt Organizations Business Master File (EO BMF) CSV consumer.
7
+ *
8
+ * The EO BMF is the IRS's authoritative registry of US tax-exempt organizations (charities,
9
+ * churches, foundations, ...), published as per-region CSVs at
10
+ * `https://www.irs.gov/charities-non-profits/exempt-organizations-business-master-file-extract-eo-bmf`
11
+ * (`eo1.csv`..`eo4.csv`, `eo_pr.csv`, `eo_xx.csv`). Each row carries an organization NAME plus
12
+ * its mailing address. It complements `usgov-nppes` with a DIFFERENT venue population
13
+ * (non-profits vs healthcare providers) and, notably, a high share of PO-box addresses — useful
14
+ * `po_box`-tag signal (a tag with historically low recall).
15
+ *
16
+ * Output: one row per record with a usable city + postcode. NAME → `venue`; the street line becomes
17
+ * `po_box` when it's a PO-box, else `house_number` + `street`; CITY/STATE/ZIP fill the locality
18
+ * line. STATE is already a USPS abbreviation in the source. License: `"Public Domain"` (US
19
+ * federal).
20
+ */
21
+
22
+ import { createReadStream } from "node:fs"
23
+
24
+ import { parse as csvParse } from "csv-parse"
25
+
26
+ import { stableSourceID } from "../../adapter.ts"
27
+ import { reconcileComponents } from "../../format.ts"
28
+ import type { AdapterOptions, CanonicalRow, CorpusAdapter } from "../../types.ts"
29
+
30
+ export const USGOV_IRS_BMF_ADAPTER_ID = "usgov-irs-bmf"
31
+ export const USGOV_IRS_BMF_DEFAULT_LICENSE = "Public Domain"
32
+
33
+ const HOUSE_NUMBER_PREFIX = /^(\d+(?:-\d+)?[A-Za-z]?)\s+(.+)$/
34
+ // PO box in its many written forms: "PO BOX 12", "P.O. BOX 12", "P O BOX 12", "POB 12", "BOX 12".
35
+ const PO_BOX = /^\s*(?:P\.?\s?O\.?\s*BOX|POB|BOX)\s+\w/i
36
+
37
+ interface IrsBmfRow {
38
+ EIN: string
39
+ NAME: string
40
+ STREET: string
41
+ CITY: string
42
+ STATE: string
43
+ ZIP: string
44
+ }
45
+
46
+ /** Classify the street line into a `po_box` or a `{house_number?, street}` split. */
47
+ function splitStreetLine(street: string): { po_box: string } | { house_number?: string; street: string } | null {
48
+ const trimmed = street.trim()
49
+
50
+ if (!trimmed) return null
51
+
52
+ if (PO_BOX.test(trimmed)) return { po_box: trimmed }
53
+ const m = HOUSE_NUMBER_PREFIX.exec(trimmed)
54
+
55
+ if (m) return { house_number: m[1], street: m[2]!.trim() }
56
+
57
+ return { street: trimmed }
58
+ }
59
+
60
+ function composeRaw(
61
+ venue: string | undefined,
62
+ streetPart: string,
63
+ city: string,
64
+ state: string,
65
+ postcode: string
66
+ ): string {
67
+ const cityPart = [city.trim(), [state, postcode].filter(Boolean).join(" ").trim()].filter(Boolean).join(", ")
68
+
69
+ return [venue, streetPart, cityPart].filter(Boolean).join(", ")
70
+ }
71
+
72
+ export function createUsgovIrsBmfAdapter(): CorpusAdapter {
73
+ return {
74
+ id: USGOV_IRS_BMF_ADAPTER_ID,
75
+ defaultLicense: USGOV_IRS_BMF_DEFAULT_LICENSE,
76
+ description:
77
+ "IRS Exempt Organizations Business Master File — US non-profit venue+address (public-domain), with strong PO-box coverage.",
78
+
79
+ async *rows(opts: AdapterOptions): AsyncIterable<CanonicalRow> {
80
+ if (opts.country && opts.country !== "US") {
81
+ throw new Error(`usgov-irs-bmf adapter: only US supported, got country=${opts.country}`)
82
+ }
83
+
84
+ const stream = createReadStream(opts.inputPath, { encoding: "utf8" })
85
+ const parser = stream.pipe(
86
+ csvParse({ columns: true, skip_empty_lines: true, relax_quotes: true, relax_column_count: true, trim: true })
87
+ )
88
+
89
+ let emitted = 0
90
+
91
+ try {
92
+ for await (const record of parser as AsyncIterable<IrsBmfRow>) {
93
+ if (opts.signal?.aborted) break
94
+
95
+ if (opts.limit !== undefined && emitted >= opts.limit) break
96
+
97
+ const ein = (record.EIN ?? "").trim()
98
+ const venue = (record.NAME ?? "").trim() || undefined
99
+ const street = (record.STREET ?? "").trim()
100
+ const city = (record.CITY ?? "").trim()
101
+ const state = (record.STATE ?? "").trim()
102
+ const zipRaw = (record.ZIP ?? "").trim()
103
+
104
+ if (!city || !zipRaw) continue
105
+ const postcode = zipRaw.split("-")[0]!.trim() // 5-digit; drop the optional +4
106
+
107
+ const split = splitStreetLine(street)
108
+
109
+ if (!split) continue
110
+
111
+ const streetPart =
112
+ "po_box" in split ? split.po_box : [split.house_number, split.street].filter(Boolean).join(" ")
113
+
114
+ const components: CanonicalRow["components"] = {
115
+ ...(venue ? { venue } : {}),
116
+ ...("po_box" in split
117
+ ? { po_box: split.po_box }
118
+ : { ...(split.house_number ? { house_number: split.house_number } : {}), street: split.street }),
119
+ locality: city,
120
+ ...(state ? { region: state } : {}),
121
+ postcode,
122
+ }
123
+
124
+ const raw = composeRaw(venue, streetPart, city, state, postcode)
125
+
126
+ if (!raw) continue
127
+
128
+ const aligned = reconcileComponents(components, raw)
129
+
130
+ if (Object.keys(aligned).length <= 2) continue
131
+
132
+ const sourceID = ein
133
+ ? `${USGOV_IRS_BMF_ADAPTER_ID}-${ein}`
134
+ : stableSourceID(USGOV_IRS_BMF_ADAPTER_ID, aligned)
135
+
136
+ yield {
137
+ raw,
138
+ components: aligned,
139
+ country: "US",
140
+ locale: "en-US",
141
+ source: USGOV_IRS_BMF_ADAPTER_ID,
142
+ source_id: sourceID,
143
+ corpus_version: "",
144
+ license: USGOV_IRS_BMF_DEFAULT_LICENSE,
145
+ }
146
+ emitted++
147
+ }
148
+ } finally {
149
+ stream.destroy()
150
+ }
151
+ },
152
+ }
153
+ }
154
+
155
+ export const usgovIrsBmfAdapter = createUsgovIrsBmfAdapter()