@mailwoman/corpus 10.5.0 → 10.7.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 (232) hide show
  1. package/data/PROVENANCE.md +24 -0
  2. package/data/address-source-register.json +18948 -8757
  3. package/data/corpus-source-inputs.json +4756 -0
  4. package/data/license-decisions.json +7260 -0
  5. package/data/licenses/whosonfirst-licenses.md +3 -2
  6. package/data/reviewed-ve-postcode-tuples.json +5 -72
  7. package/data/source-additions.json +2450 -0
  8. package/data/source-resolutions.json +87 -0
  9. package/data/training-manifests/v0.7.10-gb-county.effective.json +2335 -0
  10. package/data/training-manifests/v0.7.10-gb-county.json +1132 -0
  11. package/data/training-manifests/v0.7.2-address-systems.effective.json +3 -2
  12. package/data/training-manifests/v0.7.7-no-ppd.json +550 -0
  13. package/data/training-manifests/v0.7.8-rights-clean.json +538 -0
  14. package/data/training-manifests/v0.7.9-country-gap.json +1132 -0
  15. package/data/us-situs-sources.json +128 -0
  16. package/lib/adapters/cordis/adapter.ts +1 -1
  17. package/lib/adapters/libpostal-dictionary.ts +23 -3
  18. package/lib/adapters/osm/adapter.ts +42 -11
  19. package/lib/adapters.ts +14 -0
  20. package/lib/az/adapters/opendata/adapter.ts +389 -0
  21. package/lib/bs/adapters/gbpa/adapter.ts +248 -0
  22. package/lib/build/types.ts +16 -0
  23. package/lib/build.ts +21 -0
  24. package/lib/ee/adapters/ads/adapter.ts +18 -0
  25. package/lib/fo/adapters/adressur/adapter.ts +242 -0
  26. package/lib/gb/adapters/os-open/adapter.ts +14 -2
  27. package/lib/gb/county.ts +59 -0
  28. package/lib/gb/os-open/{open-names.ts → open/names.ts} +41 -0
  29. package/lib/gu/adapters/bsp/adapter.ts +434 -0
  30. package/lib/im/adapters/land-registry/adapter.ts +340 -0
  31. package/lib/international/recipes/locale.ts +164 -19
  32. package/lib/li/adapters/llv/adapter.ts +246 -0
  33. package/lib/recipes/bare/postcode.ts +10 -34
  34. package/lib/recipes/country-balanced.ts +95 -78
  35. package/lib/recipes/intersection.ts +22 -30
  36. package/lib/recipes/po/box/cedex/recipe.ts +8 -5
  37. package/lib/recipes/po/box/cedex/sources.ts +2 -23
  38. package/lib/recipes/scaffold.ts +28 -3
  39. package/lib/recipes/sources.ts +112 -1
  40. package/lib/recipes/street/affix.ts +17 -29
  41. package/lib/recipes/sub/venue/context.ts +33 -9
  42. package/lib/recipes/sub/venue/sources.ts +54 -13
  43. package/lib/recipes/sub/venue.ts +68 -23
  44. package/lib/recipes/unit.ts +20 -7
  45. package/lib/registers.ts +33 -0
  46. package/lib/source-register/corpus-inputs.ts +528 -0
  47. package/lib/source-register/effective-manifest.ts +37 -5
  48. package/lib/source-register/types.ts +20 -0
  49. package/lib/south-asia/recipes/register.ts +1 -1
  50. package/lib/tools/overlay/manifest.ts +109 -1
  51. package/lib/tools/source-register/build.ts +47 -1
  52. package/lib/tools/variety-sample.ts +633 -0
  53. package/lib/us/adapters/usgov/nad/adapter.ts +39 -10
  54. package/lib/us/recipes/skeleton-sources.ts +156 -0
  55. package/lib/us/tools/nad-skeletons.ts +395 -0
  56. package/lib/uy/adapters/ide/adapter.ts +275 -0
  57. package/lib/ve/recipes/reviewed-postcode-tail.ts +29 -6
  58. package/out/adapters/cordis/adapter.d.ts +1 -1
  59. package/out/adapters/cordis/adapter.js +1 -1
  60. package/out/adapters/libpostal-dictionary.d.ts +9 -0
  61. package/out/adapters/libpostal-dictionary.d.ts.map +1 -1
  62. package/out/adapters/libpostal-dictionary.js +19 -3
  63. package/out/adapters/libpostal-dictionary.js.map +1 -1
  64. package/out/adapters/osm/adapter.d.ts +12 -1
  65. package/out/adapters/osm/adapter.d.ts.map +1 -1
  66. package/out/adapters/osm/adapter.js +30 -13
  67. package/out/adapters/osm/adapter.js.map +1 -1
  68. package/out/adapters.d.ts.map +1 -1
  69. package/out/adapters.js +14 -0
  70. package/out/adapters.js.map +1 -1
  71. package/out/az/adapters/opendata/adapter.d.ts +88 -0
  72. package/out/az/adapters/opendata/adapter.d.ts.map +1 -0
  73. package/out/az/adapters/opendata/adapter.js +282 -0
  74. package/out/az/adapters/opendata/adapter.js.map +1 -0
  75. package/out/bs/adapters/gbpa/adapter.d.ts +64 -0
  76. package/out/bs/adapters/gbpa/adapter.d.ts.map +1 -0
  77. package/out/bs/adapters/gbpa/adapter.js +187 -0
  78. package/out/bs/adapters/gbpa/adapter.js.map +1 -0
  79. package/out/build/types.d.ts +15 -0
  80. package/out/build/types.d.ts.map +1 -1
  81. package/out/build/types.js.map +1 -1
  82. package/out/build.d.ts.map +1 -1
  83. package/out/build.js +17 -0
  84. package/out/build.js.map +1 -1
  85. package/out/ee/adapters/ads/adapter.d.ts +6 -0
  86. package/out/ee/adapters/ads/adapter.d.ts.map +1 -1
  87. package/out/ee/adapters/ads/adapter.js +16 -0
  88. package/out/ee/adapters/ads/adapter.js.map +1 -1
  89. package/out/fo/adapters/adressur/adapter.d.ts +63 -0
  90. package/out/fo/adapters/adressur/adapter.d.ts.map +1 -0
  91. package/out/fo/adapters/adressur/adapter.js +177 -0
  92. package/out/fo/adapters/adressur/adapter.js.map +1 -0
  93. package/out/gb/adapters/os-open/adapter.d.ts +10 -2
  94. package/out/gb/adapters/os-open/adapter.d.ts.map +1 -1
  95. package/out/gb/adapters/os-open/adapter.js +13 -2
  96. package/out/gb/adapters/os-open/adapter.js.map +1 -1
  97. package/out/gb/county.d.ts +30 -0
  98. package/out/gb/county.d.ts.map +1 -0
  99. package/out/gb/county.js +51 -0
  100. package/out/gb/county.js.map +1 -0
  101. package/out/gb/os-open/{open-names.d.ts → open/names.d.ts} +18 -1
  102. package/out/gb/os-open/open/names.d.ts.map +1 -0
  103. package/out/gb/os-open/{open-names.js → open/names.js} +20 -1
  104. package/out/gb/os-open/open/names.js.map +1 -0
  105. package/out/gb/os-open/{open-roads.d.ts → open/roads.d.ts} +1 -1
  106. package/out/gb/os-open/open/roads.d.ts.map +1 -0
  107. package/out/gb/os-open/{open-roads.js → open/roads.js} +1 -1
  108. package/out/gb/os-open/open/roads.js.map +1 -0
  109. package/out/gu/adapters/bsp/adapter.d.ts +83 -0
  110. package/out/gu/adapters/bsp/adapter.d.ts.map +1 -0
  111. package/out/gu/adapters/bsp/adapter.js +346 -0
  112. package/out/gu/adapters/bsp/adapter.js.map +1 -0
  113. package/out/im/adapters/land-registry/adapter.d.ts +68 -0
  114. package/out/im/adapters/land-registry/adapter.d.ts.map +1 -0
  115. package/out/im/adapters/land-registry/adapter.js +261 -0
  116. package/out/im/adapters/land-registry/adapter.js.map +1 -0
  117. package/out/international/recipes/locale.d.ts +24 -4
  118. package/out/international/recipes/locale.d.ts.map +1 -1
  119. package/out/international/recipes/locale.js +107 -15
  120. package/out/international/recipes/locale.js.map +1 -1
  121. package/out/li/adapters/llv/adapter.d.ts +69 -0
  122. package/out/li/adapters/llv/adapter.d.ts.map +1 -0
  123. package/out/li/adapters/llv/adapter.js +181 -0
  124. package/out/li/adapters/llv/adapter.js.map +1 -0
  125. package/out/recipes/bare/postcode.d.ts.map +1 -1
  126. package/out/recipes/bare/postcode.js +9 -30
  127. package/out/recipes/bare/postcode.js.map +1 -1
  128. package/out/recipes/country-balanced.d.ts.map +1 -1
  129. package/out/recipes/country-balanced.js +82 -73
  130. package/out/recipes/country-balanced.js.map +1 -1
  131. package/out/recipes/intersection.d.ts +3 -2
  132. package/out/recipes/intersection.d.ts.map +1 -1
  133. package/out/recipes/intersection.js +20 -26
  134. package/out/recipes/intersection.js.map +1 -1
  135. package/out/recipes/po/box/cedex/recipe.d.ts.map +1 -1
  136. package/out/recipes/po/box/cedex/recipe.js +5 -4
  137. package/out/recipes/po/box/cedex/recipe.js.map +1 -1
  138. package/out/recipes/po/box/cedex/sources.d.ts +2 -45
  139. package/out/recipes/po/box/cedex/sources.d.ts.map +1 -1
  140. package/out/recipes/po/box/cedex/sources.js +0 -20
  141. package/out/recipes/po/box/cedex/sources.js.map +1 -1
  142. package/out/recipes/scaffold.d.ts +15 -2
  143. package/out/recipes/scaffold.d.ts.map +1 -1
  144. package/out/recipes/scaffold.js +21 -1
  145. package/out/recipes/scaffold.js.map +1 -1
  146. package/out/recipes/sources.d.ts.map +1 -1
  147. package/out/recipes/sources.js +112 -1
  148. package/out/recipes/sources.js.map +1 -1
  149. package/out/recipes/street/affix.d.ts.map +1 -1
  150. package/out/recipes/street/affix.js +8 -20
  151. package/out/recipes/street/affix.js.map +1 -1
  152. package/out/recipes/sub/venue/context.d.ts +14 -3
  153. package/out/recipes/sub/venue/context.d.ts.map +1 -1
  154. package/out/recipes/sub/venue/context.js +29 -9
  155. package/out/recipes/sub/venue/context.js.map +1 -1
  156. package/out/recipes/sub/venue/sources.d.ts +41 -8
  157. package/out/recipes/sub/venue/sources.d.ts.map +1 -1
  158. package/out/recipes/sub/venue/sources.js +26 -7
  159. package/out/recipes/sub/venue/sources.js.map +1 -1
  160. package/out/recipes/sub/venue.d.ts.map +1 -1
  161. package/out/recipes/sub/venue.js +35 -14
  162. package/out/recipes/sub/venue.js.map +1 -1
  163. package/out/recipes/unit.d.ts +4 -0
  164. package/out/recipes/unit.d.ts.map +1 -1
  165. package/out/recipes/unit.js +7 -6
  166. package/out/recipes/unit.js.map +1 -1
  167. package/out/registers.d.ts +33 -0
  168. package/out/registers.d.ts.map +1 -1
  169. package/out/registers.js +33 -0
  170. package/out/registers.js.map +1 -1
  171. package/out/source-register/corpus-inputs.d.ts +240 -0
  172. package/out/source-register/corpus-inputs.d.ts.map +1 -0
  173. package/out/source-register/corpus-inputs.js +275 -0
  174. package/out/source-register/corpus-inputs.js.map +1 -0
  175. package/out/source-register/effective-manifest.d.ts +2 -0
  176. package/out/source-register/effective-manifest.d.ts.map +1 -1
  177. package/out/source-register/effective-manifest.js +33 -4
  178. package/out/source-register/effective-manifest.js.map +1 -1
  179. package/out/source-register/types.d.ts +21 -1
  180. package/out/source-register/types.d.ts.map +1 -1
  181. package/out/source-register/types.js +20 -0
  182. package/out/source-register/types.js.map +1 -1
  183. package/out/south-asia/recipes/register.js +1 -1
  184. package/out/south-asia/recipes/register.js.map +1 -1
  185. package/out/tools/overlay/manifest.d.ts +7 -0
  186. package/out/tools/overlay/manifest.d.ts.map +1 -1
  187. package/out/tools/overlay/manifest.js +72 -1
  188. package/out/tools/overlay/manifest.js.map +1 -1
  189. package/out/tools/source-register/build.d.ts +9 -0
  190. package/out/tools/source-register/build.d.ts.map +1 -1
  191. package/out/tools/source-register/build.js +25 -3
  192. package/out/tools/source-register/build.js.map +1 -1
  193. package/out/tools/variety-sample.d.ts +196 -0
  194. package/out/tools/variety-sample.d.ts.map +1 -0
  195. package/out/tools/variety-sample.js +417 -0
  196. package/out/tools/variety-sample.js.map +1 -0
  197. package/out/us/adapters/usgov/nad/adapter.d.ts +80 -0
  198. package/out/us/adapters/usgov/nad/adapter.d.ts.map +1 -1
  199. package/out/us/adapters/usgov/nad/adapter.js +27 -8
  200. package/out/us/adapters/usgov/nad/adapter.js.map +1 -1
  201. package/out/us/recipes/skeleton-sources.d.ts +92 -0
  202. package/out/us/recipes/skeleton-sources.d.ts.map +1 -0
  203. package/out/us/recipes/skeleton-sources.js +121 -0
  204. package/out/us/recipes/skeleton-sources.js.map +1 -0
  205. package/out/us/tools/nad-skeletons.d.ts +120 -0
  206. package/out/us/tools/nad-skeletons.d.ts.map +1 -0
  207. package/out/us/tools/nad-skeletons.js +262 -0
  208. package/out/us/tools/nad-skeletons.js.map +1 -0
  209. package/out/uy/adapters/ide/adapter.d.ts +64 -0
  210. package/out/uy/adapters/ide/adapter.d.ts.map +1 -0
  211. package/out/uy/adapters/ide/adapter.js +206 -0
  212. package/out/uy/adapters/ide/adapter.js.map +1 -0
  213. package/out/ve/recipes/reviewed-postcode-tail.d.ts +6 -2
  214. package/out/ve/recipes/reviewed-postcode-tail.d.ts.map +1 -1
  215. package/out/ve/recipes/reviewed-postcode-tail.js +16 -5
  216. package/out/ve/recipes/reviewed-postcode-tail.js.map +1 -1
  217. package/package.json +14 -14
  218. package/lib/recipes/unit/sources.ts +0 -44
  219. package/lib/tools/fetch/ppd.ts +0 -121
  220. package/out/gb/os-open/open-names.d.ts.map +0 -1
  221. package/out/gb/os-open/open-names.js.map +0 -1
  222. package/out/gb/os-open/open-roads.d.ts.map +0 -1
  223. package/out/gb/os-open/open-roads.js.map +0 -1
  224. package/out/recipes/unit/sources.d.ts +0 -28
  225. package/out/recipes/unit/sources.d.ts.map +0 -1
  226. package/out/recipes/unit/sources.js +0 -30
  227. package/out/recipes/unit/sources.js.map +0 -1
  228. package/out/tools/fetch/ppd.d.ts +0 -15
  229. package/out/tools/fetch/ppd.d.ts.map +0 -1
  230. package/out/tools/fetch/ppd.js +0 -86
  231. package/out/tools/fetch/ppd.js.map +0 -1
  232. /package/lib/gb/os-open/{open-roads.ts → open/roads.ts} +0 -0
@@ -26,7 +26,10 @@ export const USGOV_NAD_ADAPTER_ID = "usgov-nad"
26
26
  */
27
27
  export const USGOV_NAD_DEFAULT_LICENSE = "Public Domain"
28
28
 
29
- interface NADRecord {
29
+ /**
30
+ * One NAD address point as the FeatureServer pages publish it, limited to the fields a reader here uses.
31
+ */
32
+ export interface NADRecord {
30
33
  OBJECTID?: number
31
34
  UUID?: string | null
32
35
  AddNum_Pre?: string | null
@@ -94,7 +97,7 @@ function nonEmpty(...values: Array<string | null | undefined>): string | null {
94
97
  * The shared `composeHouseNumber` in `#adapters/street-line` joins exactly two columns with
95
98
  * one separator, so it fits a publisher that splits the number in two rather than in four.
96
99
  */
97
- function nadHouseNumber(r: NADRecord): string | null {
100
+ export function nadHouseNumber(r: NADRecord): string | null {
98
101
  const full = (r.AddNo_Full ?? "").toString().trim()
99
102
 
100
103
  if (full) return full
@@ -107,16 +110,24 @@ function nadHouseNumber(r: NADRecord): string | null {
107
110
  return [pre, num, suf].filter(isPresent).join(" ").trim() || null
108
111
  }
109
112
 
110
- interface DecomposedNADStreet {
113
+ /**
114
+ * A NAD street split into the prefix, name and suffix its structured columns state,
115
+ * with the whole street beside them.
116
+ */
117
+ export interface DecomposedNADStreet {
111
118
  prefix?: string
112
119
  street?: string
113
120
  suffix?: string
114
121
  full: string
115
122
  }
116
123
 
117
- // Structured street fields take precedence.
118
- // If they are empty, `StNam_Full` supplies the whole street.
119
- function decomposeNADStreet(r: NADRecord): DecomposedNADStreet | null {
124
+ /**
125
+ * The street a NAD record states.
126
+ *
127
+ * Structured street fields take precedence.
128
+ * If they are empty, `StNam_Full` supplies the whole street.
129
+ */
130
+ export function decomposeNADStreet(r: NADRecord): DecomposedNADStreet | null {
120
131
  const name = (r.St_Name ?? "").toString().trim()
121
132
 
122
133
  if (name) {
@@ -139,9 +150,27 @@ function decomposeNADStreet(r: NADRecord): DecomposedNADStreet | null {
139
150
  return null
140
151
  }
141
152
 
142
- // The postal city comes first because it is the name that people write on mail.
143
- function composeLocality(r: NADRecord): string | null {
144
- return nonEmpty(r.Post_City, r.Inc_Muni, r.Census_Plc, r.Uninc_Comm)
153
+ /**
154
+ * The NAD domain values that fill a place-name column without naming a place, lower-cased.
155
+ */
156
+ const NAD_PLACEHOLDER_PLACE_NAMES: ReadonlySet<string> = new Set(["not stated", "unincorporated", "0"])
157
+
158
+ /**
159
+ * The locality a NAD record states.
160
+ *
161
+ * The postal city comes first because it is the name that people write on mail.
162
+ * NAD fills an unknown `Post_City` with the domain value `Not stated`
163
+ * (99,998 rows of a 100,000-row Massachusetts sample and 11,713 of a Texas one), and an address
164
+ * outside any municipality has `Inc_Muni` `Unincorporated` (the same 11,713 Texas rows).
165
+ *
166
+ * Another 23 rows of that Texas sample read `0`.
167
+ * Each of these values counts as absent, and the next column supplies the place name.
168
+ */
169
+ export function composeNADLocality(r: NADRecord): string | null {
170
+ const stated = (value: string | null | undefined) =>
171
+ NAD_PLACEHOLDER_PLACE_NAMES.has(value?.trim().toLowerCase() ?? "") ? null : value
172
+
173
+ return nonEmpty(stated(r.Post_City), stated(r.Inc_Muni), stated(r.Census_Plc), stated(r.Uninc_Comm))
145
174
  }
146
175
 
147
176
  function composePostcode(r: NADRecord): string | null {
@@ -202,7 +231,7 @@ export function createUsgovNADAdapter(): CorpusAdapter {
202
231
 
203
232
  if (!US_STATES_SET.has(state)) continue
204
233
 
205
- const locality = composeLocality(record)
234
+ const locality = composeNADLocality(record)
206
235
 
207
236
  if (!locality) continue
208
237
 
@@ -0,0 +1,156 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ * @file The US address skeletons the OA-shaped recipes read: two elected OpenAddresses extracts and one
6
+ * National Address Database sample per chosen state, with Vermont held out for the golden sets.
7
+ *
8
+ * `unit`, `street-affix`, `suffix-boundary`, `po-box-cedex` and `country-balanced` read these files
9
+ * through the same zipped-CSV reader, so a NAD sample is written in OpenAddresses' column layout by
10
+ * `packages/mailwoman/tools/dev-tools/corpus/nad-skeletons.run.ts`.
11
+ */
12
+
13
+ import { dataRootPath } from "@mailwoman/core/data-root"
14
+ import type { PathBuilderLike } from "path-ts"
15
+
16
+ /**
17
+ * The register a skeleton file's rows were published in.
18
+ *
19
+ * It prefixes the `base_source_id` a recipe derives from the file.
20
+ */
21
+ export const USSkeletonRegister = {
22
+ OpenAddresses: "openaddresses",
23
+ NationalAddressDatabase: "usgov-nad",
24
+ } as const
25
+
26
+ export type USSkeletonRegister = (typeof USSkeletonRegister)[keyof typeof USSkeletonRegister]
27
+
28
+ /**
29
+ * One cached US skeleton file: the zip, its CSV member and the region every row of it lies in.
30
+ */
31
+ export interface USSkeletonSource {
32
+ zip: PathBuilderLike
33
+ csv: string
34
+ region: string
35
+ register: USSkeletonRegister
36
+ }
37
+
38
+ /**
39
+ * The data-root directory holding the NAD samples and ZIP-to-city tables the NAD skeleton tool writes.
40
+ */
41
+ export const NAD_SKELETON_DIRECTORY = "nad-skeletons"
42
+
43
+ /**
44
+ * The states sampled from NAD, chosen for address shapes the OpenAddresses files lack
45
+ * rather than for row count.
46
+ *
47
+ * Distinct tuples of those six files carry a unit on 0.6% of rows, and none has
48
+ * a hyphenated or direction-prefixed house number.
49
+ *
50
+ * Each comment states the shape measured over the usable rows of the FeatureServer
51
+ * pages fetched on 2026-05-18.
52
+ * `nad-skeletons.run.ts` prints the table for every state.
53
+ *
54
+ * Vermont is absent because it is the eval holdout.
55
+ * No state's rows match a rural-route street pattern, so NAD adds no rural-route form.
56
+ */
57
+ export const NAD_SKELETON_STATES = [
58
+ // Chicago's city grid, which `us/il/cook` supplied before it was withdrawn: 8.9% unit, 1,268 ZIPs.
59
+ "IL",
60
+ // 36.6% unit, the most of any state with over a million rows, and 154 street types.
61
+ "MA",
62
+ // The numbered grid: 43.1% numeric street names and 42.6% post-directionals, as in `500 E`.
63
+ "UT",
64
+ // Direction-prefixed rural grid house numbers such as `S9878` on 1.3% of rows, and lettered county roads.
65
+ "WI",
66
+ // Hyphenated Queens house numbers such as `244-32` on 5.3% of rows, and 15.8% unit.
67
+ "NY",
68
+ // Farm to Market, Ranch Road and County Road prefix types on 5.4% of rows, and 161 street types.
69
+ "TX",
70
+ ] as const
71
+
72
+ /**
73
+ * The zip and CSV member the NAD skeleton tool writes for one state.
74
+ */
75
+ export function nadSkeletonFile(region: string): { zip: PathBuilderLike; csv: string } {
76
+ const state = region.toLowerCase()
77
+
78
+ return {
79
+ zip: dataRootPath(NAD_SKELETON_DIRECTORY, `us__${state}__nad.zip`),
80
+ csv: `us/${state}/nad.csv`,
81
+ }
82
+ }
83
+
84
+ /**
85
+ * The NAD skeleton file for every state in {@link NAD_SKELETON_STATES}.
86
+ */
87
+ export const NAD_SKELETON_SOURCES: readonly USSkeletonSource[] = NAD_SKELETON_STATES.map((region) => ({
88
+ ...nadSkeletonFile(region),
89
+ region,
90
+ register: USSkeletonRegister.NationalAddressDatabase,
91
+ }))
92
+
93
+ /**
94
+ * The cached OpenAddresses US extracts used for training.
95
+ *
96
+ * `us/il/cook` is absent because Cook County's terms restrict redistribution.
97
+ * NAD's Illinois sample stands in for its city grid.
98
+ */
99
+ export const OA_SKELETON_TRAIN_SOURCES: readonly USSkeletonSource[] = (
100
+ [
101
+ ["us__ca__berkeley.zip", "us/ca/berkeley.csv", "CA"],
102
+ ["us__ca__marin.zip", "us/ca/marin.csv", "CA"],
103
+ ["us__dc__statewide.zip", "us/dc/statewide.csv", "DC"],
104
+ ["us__ia__statewide.zip", "us/ia/statewide.csv", "IA"],
105
+ ["us__mt__statewide.zip", "us/mt/statewide.csv", "MT"],
106
+ ["us__sd__statewide.zip", "us/sd/statewide.csv", "SD"],
107
+ ] as const
108
+ ).map(([zip, csv, region]) => ({
109
+ zip: dataRootPath("oa-cache", zip),
110
+ csv,
111
+ region,
112
+ register: USSkeletonRegister.OpenAddresses,
113
+ }))
114
+
115
+ /**
116
+ * Every US training skeleton: the OpenAddresses extracts and the NAD samples.
117
+ */
118
+ export const US_SKELETON_TRAIN_SOURCES: readonly USSkeletonSource[] = [
119
+ ...OA_SKELETON_TRAIN_SOURCES,
120
+ ...NAD_SKELETON_SOURCES,
121
+ ]
122
+
123
+ /**
124
+ * Vermont, the corpus holdout — `--golden` reads this and no other US source.
125
+ */
126
+ export const US_SKELETON_EVAL_SOURCE: USSkeletonSource = {
127
+ zip: dataRootPath("oa-cache", "us__vt__statewide.zip"),
128
+ csv: "us/vt/statewide.csv",
129
+ region: "VT",
130
+ register: USSkeletonRegister.OpenAddresses,
131
+ }
132
+
133
+ /**
134
+ * A county whose NAD rows give the ZIP-to-city table the `intersection` recipe renders city tails from.
135
+ */
136
+ export interface NADZipCityCounty {
137
+ region: string
138
+ county: string
139
+ }
140
+
141
+ /**
142
+ * The counties of the `intersection` recipe's TIGER training crossings, Cook IL and Morris NJ.
143
+ */
144
+ export const NAD_ZIP_CITY_COUNTIES: readonly NADZipCityCounty[] = [
145
+ { region: "IL", county: "Cook" },
146
+ { region: "NJ", county: "Morris" },
147
+ ]
148
+
149
+ /**
150
+ * The ZIP-to-city CSV the NAD skeleton tool writes for one county.
151
+ */
152
+ export function nadZipCityPath(entry: NADZipCityCounty): PathBuilderLike {
153
+ const county = entry.county.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")
154
+
155
+ return dataRootPath(NAD_SKELETON_DIRECTORY, `us__${entry.region.toLowerCase()}__${county}__zip-city.csv`)
156
+ }
@@ -0,0 +1,395 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ * @file Samples National Address Database rows into OpenAddresses-layout CSV skeletons, measures the address
6
+ * shapes each state's rows carry, and tallies county ZIP-to-city tables.
7
+ *
8
+ * The OA-skeleton recipes read a zipped CSV with OpenAddresses' columns, so a NAD sample written in that
9
+ * layout reaches them through the same reader as the OpenAddresses extracts they also read.
10
+ */
11
+
12
+ import { matchLeadingDesignator } from "@mailwoman/codex/us"
13
+ import { pathExists } from "@mailwoman/core/fs/readers/stat"
14
+ import { writeLocalBuffer, writeLocalJSONFile, writeLocalTextFile } from "@mailwoman/core/fs/writers"
15
+ import { sha256Hex } from "@mailwoman/core/hash"
16
+ import { tryParsingJSON } from "@mailwoman/core/json"
17
+ import { mulberry32 } from "@mailwoman/core/random"
18
+ import ADMZip from "adm-zip"
19
+ import { resolvePathBuilder, type PathBuilderLike } from "path-ts"
20
+ import { TextSpliterator } from "spliterator"
21
+ import { Globerator } from "spliterator/node/fs"
22
+
23
+ import { composeNADLocality, decomposeNADStreet, nadHouseNumber, type NADRecord } from "#us/adapters/usgov/nad/adapter"
24
+ import { nadSkeletonFile, nadZipCityPath, type NADZipCityCounty } from "#us/recipes/skeleton-sources"
25
+
26
+ /**
27
+ * The OpenAddresses columns a skeleton CSV carries, in OpenAddresses' order.
28
+ */
29
+ export const NAD_SKELETON_COLUMNS = [
30
+ "NUMBER",
31
+ "STREET",
32
+ "UNIT",
33
+ "CITY",
34
+ "DISTRICT",
35
+ "REGION",
36
+ "POSTCODE",
37
+ "ID",
38
+ ] as const
39
+
40
+ /**
41
+ * One NAD row projected onto {@link NAD_SKELETON_COLUMNS}.
42
+ */
43
+ export type NADSkeletonRow = Record<(typeof NAD_SKELETON_COLUMNS)[number], string>
44
+
45
+ const RURAL_ROUTE = /^(RR|R R|RURAL ROUTE|HC|HCR|HIGHWAY CONTRACT)\b/i
46
+ const text = (value: unknown): string => (value == null ? "" : String(value).trim())
47
+
48
+ /**
49
+ * The unit identifier of a NAD row without its designator, as OpenAddresses' `UNIT` column mostly states it.
50
+ *
51
+ * NAD writes `APT 6`, `UNIT D` or a bare `200`; the recipes add their own designator in
52
+ * front of the identifier, so the designator NAD wrote is dropped rather than doubled.
53
+ */
54
+ export function nadUnitIdentifier(record: NADRecord): string {
55
+ const unit = text(record.Unit) || text(record.Room)
56
+
57
+ if (!unit) return ""
58
+
59
+ const designator = matchLeadingDesignator(unit)
60
+ const identifier = designator ? unit.slice(unit.indexOf(designator.matched) + designator.matched.length) : unit
61
+
62
+ return identifier.replace(/^\s*#\s*/, "").trim()
63
+ }
64
+
65
+ /**
66
+ * Projects one NAD record onto the OpenAddresses columns, or returns null
67
+ * when it lacks a house number, street, locality, state or five-digit ZIP.
68
+ *
69
+ * A street whose name NAD publishes in capitals is written wholly in capitals,
70
+ * because NAD spells its structured types in title case (`East MAIN Street`)
71
+ * and a recipe would otherwise teach that mixed case.
72
+ */
73
+ export function nadSkeletonRow(record: NADRecord): NADSkeletonRow | null {
74
+ const number = nadHouseNumber(record)
75
+ const street = decomposeNADStreet(record)
76
+ const city = composeNADLocality(record)
77
+ const region = text(record.State).toUpperCase()
78
+ const postcode = text(record.Zip_Code)
79
+
80
+ if (!number || !street?.full || !city || !region || !/^\d{5}$/.test(postcode)) return null
81
+
82
+ const name = street.street ?? street.full
83
+ const capitalized = /[A-Z]/.test(name) && name === name.toUpperCase()
84
+
85
+ return {
86
+ NUMBER: number,
87
+ STREET: capitalized ? street.full.toUpperCase() : street.full,
88
+ UNIT: nadUnitIdentifier(record),
89
+ CITY: city,
90
+ DISTRICT: text(record.County),
91
+ REGION: region,
92
+ POSTCODE: postcode,
93
+ ID: text(record.UUID) || text(record.OBJECTID),
94
+ }
95
+ }
96
+
97
+ function csvCell(value: string): string {
98
+ return /[",\r\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value
99
+ }
100
+
101
+ /**
102
+ * The address-shape measures for one state's usable NAD rows.
103
+ */
104
+ export interface NADStateShape {
105
+ region: string
106
+ /**
107
+ * Rows with a house number, street, locality and five-digit ZIP.
108
+ */
109
+ usable: number
110
+ unitShare: number
111
+ /**
112
+ * Distinct `St_PosTyp` and `St_PreTyp` values.
113
+ */
114
+ streetTypes: number
115
+ zips: number
116
+ /**
117
+ * Share of rows whose street opens with a type, such as `County Road 12` or `Farm to Market 1960`.
118
+ */
119
+ prefixTypeShare: number
120
+ numericNameShare: number
121
+ postDirectionalShare: number
122
+ ruralRouteShare: number
123
+ hyphenatedNumberShare: number
124
+ /**
125
+ * Share of house numbers that open with a direction letter, as Wisconsin's `E2755` does.
126
+ */
127
+ gridNumberShare: number
128
+ }
129
+
130
+ interface ShapeTally {
131
+ usable: number
132
+ unit: number
133
+ types: Set<string>
134
+ zips: Set<string>
135
+ prefixType: number
136
+ numericName: number
137
+ postDirectional: number
138
+ ruralRoute: number
139
+ hyphenated: number
140
+ grid: number
141
+ }
142
+
143
+ const newTally = (): ShapeTally => ({
144
+ usable: 0,
145
+ unit: 0,
146
+ types: new Set(),
147
+ zips: new Set(),
148
+ prefixType: 0,
149
+ numericName: 0,
150
+ postDirectional: 0,
151
+ ruralRoute: 0,
152
+ hyphenated: 0,
153
+ grid: 0,
154
+ })
155
+
156
+ function tallyShape(tally: ShapeTally, record: NADRecord, row: NADSkeletonRow): void {
157
+ tally.usable++
158
+
159
+ if (row.UNIT) {
160
+ tally.unit++
161
+ }
162
+
163
+ const postType = text(record.St_PosTyp).toUpperCase()
164
+ const preType = text(record.St_PreTyp).toUpperCase()
165
+
166
+ if (postType) {
167
+ tally.types.add(postType)
168
+ }
169
+
170
+ if (preType) {
171
+ tally.types.add(preType)
172
+
173
+ tally.prefixType++
174
+ }
175
+
176
+ tally.zips.add(row.POSTCODE)
177
+
178
+ if (/^\d+$/.test(text(record.St_Name))) {
179
+ tally.numericName++
180
+ }
181
+
182
+ if (text(record.St_PosDir)) {
183
+ tally.postDirectional++
184
+ }
185
+
186
+ if (RURAL_ROUTE.test(row.STREET)) {
187
+ tally.ruralRoute++
188
+ }
189
+
190
+ if (/^\d+-\d+/.test(row.NUMBER)) {
191
+ tally.hyphenated++
192
+ }
193
+
194
+ if (/^[NSEW]\d/i.test(row.NUMBER)) {
195
+ tally.grid++
196
+ }
197
+ }
198
+
199
+ function shapeOf(region: string, t: ShapeTally): NADStateShape {
200
+ const share = (n: number) => (t.usable ? n / t.usable : 0)
201
+
202
+ return {
203
+ region,
204
+ usable: t.usable,
205
+ unitShare: share(t.unit),
206
+ streetTypes: t.types.size,
207
+ zips: t.zips.size,
208
+ prefixTypeShare: share(t.prefixType),
209
+ numericNameShare: share(t.numericName),
210
+ postDirectionalShare: share(t.postDirectional),
211
+ ruralRouteShare: share(t.ruralRoute),
212
+ hyphenatedNumberShare: share(t.hyphenated),
213
+ gridNumberShare: share(t.grid),
214
+ }
215
+ }
216
+
217
+ export interface BuildNADSkeletonsOptions {
218
+ /**
219
+ * The directory of FeatureServer NDJSON pages the NAD fetcher writes.
220
+ */
221
+ inputDirectory: PathBuilderLike
222
+ /**
223
+ * The states to sample, as USPS abbreviations.
224
+ */
225
+ states: readonly string[]
226
+ /**
227
+ * The most rows any one state's sample keeps.
228
+ */
229
+ cap: number
230
+ seed: number
231
+ zipCityCounties: readonly NADZipCityCounty[]
232
+ /**
233
+ * Called after each page with the count read so far.
234
+ */
235
+ onPage?: (pagesRead: number, pages: number) => void
236
+ }
237
+
238
+ export interface NADSkeletonSummary {
239
+ pages: number
240
+ cap: number
241
+ seed: number
242
+ shapes: NADStateShape[]
243
+ samples: Array<{ region: string; rows: number; zip: string; csv: string; csvSHA256: string }>
244
+ zipCity: Array<{ region: string; county: string; zips: number; path: string; sha256: string }>
245
+ }
246
+
247
+ /**
248
+ * Reads every NAD page once, measures every state's address shapes,
249
+ * keeps a uniform reservoir sample of at most `cap` rows for each requested state,
250
+ * and tallies the majority city of each ZIP in each requested county.
251
+ *
252
+ * The sample is written in reservoir order rather than page order, so a reader that
253
+ * takes the first N rows still draws across the whole state.
254
+ * It refuses to replace a file that already exists.
255
+ */
256
+ export async function buildNADSkeletons(options: BuildNADSkeletonsOptions): Promise<NADSkeletonSummary> {
257
+ const { inputDirectory, cap, seed } = options
258
+ const states = new Set(options.states.map((s) => s.toUpperCase()))
259
+
260
+ const outputs = [
261
+ ...[...states].map((region) => nadSkeletonFile(region).zip),
262
+ ...options.zipCityCounties.map(nadZipCityPath),
263
+ ]
264
+
265
+ for (const output of outputs) {
266
+ if (await pathExists(output)) throw new Error(`${output} already exists; a skeleton artifact is never replaced`)
267
+ }
268
+
269
+ const random = mulberry32(seed)
270
+ const tallies = new Map<string, ShapeTally>()
271
+ const reservoirs = new Map<string, { seen: number; rows: NADSkeletonRow[] }>()
272
+ const zipCity = new Map<string, Map<string, Map<string, number>>>()
273
+ const countyKey = (region: string, county: string) => `${region}|${county.toLowerCase()}`
274
+ const wantedCounties = new Set(options.zipCityCounties.map((c) => countyKey(c.region, c.county)))
275
+
276
+ const pages = await Globerator.files("ndjson", { cwd: inputDirectory, absolute: false, recursive: false }).toSorted()
277
+
278
+ let pagesRead = 0
279
+
280
+ for (const page of pages) {
281
+ for await (const line of TextSpliterator.fromAsync(resolvePathBuilder(inputDirectory, page))) {
282
+ if (!line) continue
283
+ const record = tryParsingJSON<NADRecord>(line)
284
+
285
+ if (!record) continue
286
+ const row = nadSkeletonRow(record)
287
+
288
+ if (!row) continue
289
+
290
+ let tally = tallies.get(row.REGION)
291
+
292
+ if (!tally) {
293
+ tallies.set(row.REGION, (tally = newTally()))
294
+ }
295
+
296
+ tallyShape(tally, record, row)
297
+
298
+ if (states.has(row.REGION)) {
299
+ let reservoir = reservoirs.get(row.REGION)
300
+
301
+ if (!reservoir) {
302
+ reservoirs.set(row.REGION, (reservoir = { seen: 0, rows: [] }))
303
+ }
304
+
305
+ reservoir.seen++
306
+
307
+ if (reservoir.rows.length < cap) {
308
+ reservoir.rows.push(row)
309
+ } else {
310
+ const slot = Math.floor(random() * reservoir.seen)
311
+
312
+ if (slot < cap) {
313
+ reservoir.rows[slot] = row
314
+ }
315
+ }
316
+ }
317
+
318
+ const key = countyKey(row.REGION, row.DISTRICT)
319
+
320
+ if (wantedCounties.has(key)) {
321
+ let byZip = zipCity.get(key)
322
+
323
+ if (!byZip) {
324
+ zipCity.set(key, (byZip = new Map()))
325
+ }
326
+
327
+ let byCity = byZip.get(row.POSTCODE)
328
+
329
+ if (!byCity) {
330
+ byZip.set(row.POSTCODE, (byCity = new Map()))
331
+ }
332
+
333
+ byCity.set(row.CITY, (byCity.get(row.CITY) ?? 0) + 1)
334
+ }
335
+ }
336
+
337
+ options.onPage?.(++pagesRead, pages.length)
338
+ }
339
+
340
+ const summary: NADSkeletonSummary = {
341
+ pages: pages.length,
342
+ cap,
343
+ seed,
344
+ shapes: [...tallies].map(([region, t]) => shapeOf(region, t)).toSorted((a, b) => b.usable - a.usable),
345
+ samples: [],
346
+ zipCity: [],
347
+ }
348
+
349
+ for (const region of states) {
350
+ const reservoir = reservoirs.get(region)
351
+
352
+ if (!reservoir?.rows.length) throw new Error(`NAD holds no usable row for ${region}`)
353
+ const { zip, csv } = nadSkeletonFile(region)
354
+
355
+ const body = [
356
+ NAD_SKELETON_COLUMNS.join(","),
357
+ ...reservoir.rows.map((row) => NAD_SKELETON_COLUMNS.map((column) => csvCell(row[column])).join(",")),
358
+ "",
359
+ ].join("\n")
360
+
361
+ const archive = new ADMZip()
362
+
363
+ archive.addFile(csv, Buffer.from(body, "utf8"))
364
+ await writeLocalBuffer(archive.toBuffer(), zip)
365
+ summary.samples.push({ region, rows: reservoir.rows.length, zip: String(zip), csv, csvSHA256: sha256Hex(body) })
366
+ }
367
+
368
+ for (const county of options.zipCityCounties) {
369
+ const byZip = zipCity.get(countyKey(county.region, county.county))
370
+
371
+ if (!byZip?.size) throw new Error(`NAD holds no usable row for ${county.county} County, ${county.region}`)
372
+ const lines = ["ZIP,CITY,ROWS"]
373
+
374
+ for (const [zip, byCity] of [...byZip].toSorted(([a], [b]) => a.localeCompare(b))) {
375
+ const [city, rows] = [...byCity].toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]!
376
+
377
+ lines.push([zip, csvCell(city), String(rows)].join(","))
378
+ }
379
+
380
+ const body = `${lines.join("\n")}\n`
381
+ const path = nadZipCityPath(county)
382
+
383
+ await writeLocalTextFile(body, path)
384
+ summary.zipCity.push({ ...county, zips: byZip.size, path: String(path), sha256: sha256Hex(body) })
385
+ }
386
+
387
+ return summary
388
+ }
389
+
390
+ /**
391
+ * Writes the summary beside the skeletons it describes.
392
+ */
393
+ export function writeNADSkeletonSummary(summary: NADSkeletonSummary, path: PathBuilderLike): Promise<void> {
394
+ return writeLocalJSONFile(summary, path)
395
+ }